diff --git a/extensions/amazon-bedrock/bedrock-options.ts b/extensions/amazon-bedrock/bedrock-options.ts index 10874e99539c..39397e36805a 100644 --- a/extensions/amazon-bedrock/bedrock-options.ts +++ b/extensions/amazon-bedrock/bedrock-options.ts @@ -85,25 +85,9 @@ export function supportsBedrockClaudePromptCaching(modelId: string, modelName?: } return false; } - if (candidates.some((s) => s.includes("-4-"))) { - return true; - } - if ( - candidates.some( - (candidate) => - candidate.includes("claude-fable-5") || - candidate.includes("claude-mythos-5") || - candidate.includes("claude-opus-5") || - candidate.includes("claude-sonnet-5"), - ) - ) { - return true; - } - if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) { - return true; - } - if (candidates.some((s) => s.includes("claude-3-5-haiku"))) { - return true; - } - return false; + return candidates.some( + (candidate) => + candidate.includes("-4-") || + /claude-(?:fable-5|mythos-5|opus-5|sonnet-5|3-7-sonnet|3-5-haiku)/.test(candidate), + ); } diff --git a/extensions/amazon-bedrock/register.sync.runtime.ts b/extensions/amazon-bedrock/register.sync.runtime.ts index cab963f67450..3559d2ede734 100644 --- a/extensions/amazon-bedrock/register.sync.runtime.ts +++ b/extensions/amazon-bedrock/register.sync.runtime.ts @@ -290,14 +290,12 @@ function injectBedrockCachePoints( // Inject into the last user message if missing. // Bedrock Converse uses lowercase roles ("user" / "assistant"). const messages = payload.messages as BedrockMessage[] | undefined; - if (Array.isArray(messages) && messages.length > 0) { - for (const msg of messages.toReversed()) { - if (msg.role === "user" && Array.isArray(msg.content)) { - if (!hasCachePoint(msg.content)) { - msg.content.push(point); - } - break; - } + if (Array.isArray(messages)) { + const userContent = messages + .toReversed() + .find((msg) => msg.role === "user" && Array.isArray(msg.content))?.content; + if (userContent && !hasCachePoint(userContent)) { + userContent.push(point); } } } diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index ec9b22b17b82..91ac10d73759 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -1208,19 +1208,10 @@ function hasConfiguredBedrockProfile(options: BedrockOptions): boolean { } function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined { - if (!baseUrl) { - return undefined; - } - - try { - const { hostname } = new URL(baseUrl); - const match = hostname - .toLowerCase() - .match(/^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$/); - return match?.[1]; - } catch { - return undefined; - } + const hostname = baseUrl ? URL.parse(baseUrl)?.hostname : undefined; + return hostname + ?.toLowerCase() + .match(/^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$/)?.[1]; } function isGovCloudBedrockTarget( diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index e3608156960f..746934fac2f3 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -23,9 +23,6 @@ import { wrapCloudflareAiGatewayProviderStream } from "./stream-wrappers.js"; const PROVIDER_ID = "cloudflare-ai-gateway"; const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY"; const PROFILE_ID = "cloudflare-ai-gateway:default"; -function readRequiredTextInput(value: unknown): string { - return normalizeOptionalString(value) ?? ""; -} async function resolveCloudflareGatewayMetadataInteractive( ctx: Pick & { accountId?: string; gatewayId?: string }, @@ -35,16 +32,16 @@ async function resolveCloudflareGatewayMetadataInteractive( if (!accountId) { const value = await ctx.prompter.text({ message: "Enter Cloudflare Account ID", - validate: (val) => (readRequiredTextInput(val) ? undefined : "Account ID is required"), + validate: (val) => (normalizeOptionalString(val) ? undefined : "Account ID is required"), }); - accountId = readRequiredTextInput(value); + accountId = normalizeOptionalString(value) ?? ""; } if (!gatewayId) { const value = await ctx.prompter.text({ message: "Enter Cloudflare AI Gateway ID", - validate: (val) => (readRequiredTextInput(val) ? undefined : "Gateway ID is required"), + validate: (val) => (normalizeOptionalString(val) ? undefined : "Gateway ID is required"), }); - gatewayId = readRequiredTextInput(value); + gatewayId = normalizeOptionalString(value) ?? ""; } return { accountId, gatewayId }; } diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index 024d9075d67f..7a63277a86e3 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -70,7 +70,7 @@ import { createCodexSessionCatalogControl, createCodexSessionCatalogNodeHostCommands, createCodexSessionCatalogNodeInvokePolicies, - codexSessionCatalogRuntime, + registerCodexSessionCatalog, } from "./src/session-catalog.js"; import { CODEX_SUPERVISION_COMPAT_TOOL_NAMES, @@ -221,7 +221,7 @@ export default definePluginEntry({ stop: () => sessionCatalogControlFactory.stop(), }); if (sessionCatalogEnabled) { - codexSessionCatalogRuntime.register({ + registerCodexSessionCatalog({ api, resolveRuntimeOptions: resolveCodexSupervisionAppServerRuntimeOptions, bindingStore, diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index e3ca69336c94..2aa2415ef809 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -10,7 +10,10 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints"; import type { TranscriptTurnAdmission } from "openclaw/plugin-sdk/session-transcript-runtime"; -import { readNonBlankString as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + readNonBlankString as readNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { CODEX_MEMORY_CONTEXT_BASENAME, @@ -19,7 +22,6 @@ import { getCodexContextFileDisplayBasename, isNonEmptyString, normalizeCodexContextFilePath, - normalizeCodexDynamicToolName, type CodexBootstrapFile, type CodexWorkspaceBootstrapContext, } from "./attempt-workspace-context.js"; @@ -389,7 +391,7 @@ export function buildCodexWatchedSessionsContext(params: { sessionKey: params.sessionKey, sandboxed: params.sandboxed, toolNames: flattenCodexDynamicToolFunctions(params.dynamicTools).map((tool) => - normalizeCodexDynamicToolName(tool.name), + normalizeLowercaseStringOrEmpty(tool.name), ), }); } @@ -404,7 +406,7 @@ export function renderCodexSkillsInstructions(params: { } const names = new Set( flattenCodexDynamicToolFunctions(params.dynamicTools ?? []).map((tool) => - normalizeCodexDynamicToolName(tool.name), + normalizeLowercaseStringOrEmpty(tool.name), ), ); const prompt = params.skillsPrompt?.trim(); diff --git a/extensions/codex/src/app-server/attempt-workspace-context.ts b/extensions/codex/src/app-server/attempt-workspace-context.ts index 9b82a772899c..eef2ca88303d 100644 --- a/extensions/codex/src/app-server/attempt-workspace-context.ts +++ b/extensions/codex/src/app-server/attempt-workspace-context.ts @@ -12,6 +12,7 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { resolveBootstrapFilesForPreparation } from "openclaw/plugin-sdk/codex-mcp-projection"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isMessageOnlyCodexSourceReply } from "./dynamic-tool-profile.js"; import { flattenCodexDynamicToolFunctions, type CodexDynamicToolSpec } from "./protocol.js"; @@ -81,7 +82,7 @@ export async function buildCodexWorkspaceBootstrapContext(params: { }): Promise { const availableToolNames = new Set( flattenCodexDynamicToolFunctions(params.tools).map((tool) => - normalizeCodexDynamicToolName(tool.name), + normalizeLowercaseStringOrEmpty(tool.name), ), ); const executionWorkspace = params.executionWorkspace ?? params.resolvedWorkspace; @@ -250,7 +251,7 @@ function renderCodexWorkspaceMemoryInstructions(params: { function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string | undefined { const memoryToolNames = toolNames - .map((name) => normalizeCodexDynamicToolName(name)) + .map(normalizeLowercaseStringOrEmpty) .filter((name) => CODEX_MEMORY_TOOL_NAMES.has(name)) .toSorted(); if (memoryToolNames.length === 0) { @@ -300,10 +301,6 @@ export function getCodexContextFileBasename(filePath: string): string { return normalizeCodexContextFilePath(filePath).split("/").pop() ?? ""; } -export function normalizeCodexDynamicToolName(name: string): string { - return name.trim().toLowerCase(); -} - export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } diff --git a/extensions/codex/src/app-server/canonical-fork.test-support.ts b/extensions/codex/src/app-server/canonical-fork.test-support.ts index d4c53dbd95e7..3783be7bcb44 100644 --- a/extensions/codex/src/app-server/canonical-fork.test-support.ts +++ b/extensions/codex/src/app-server/canonical-fork.test-support.ts @@ -14,7 +14,7 @@ import { readVisibleSessionTranscriptMessageEntries } from "openclaw/plugin-sdk/ import { createStageTimingTracker } from "openclaw/plugin-sdk/time-runtime"; import { continueLocalCodexSession } from "../session-catalog-adoption.js"; import { createCodexSessionCatalogControl } from "../session-catalog-control.js"; -import { codexSessionCatalogRuntime } from "../session-catalog.js"; +import { registerCodexSessionCatalog } from "../session-catalog.js"; import { codexUpstreamContinueResult, type CodexUpstreamBaseline, @@ -125,7 +125,7 @@ export async function createCanonicalForkFixture(params: { ); const captured = createCapturedPluginRegistration({ id: "codex", config }); const api = { ...captured.api, runtime }; - codexSessionCatalogRuntime.register({ + registerCodexSessionCatalog({ resolveRuntimeOptions: resolveCodexSupervisionAppServerRuntimeOptions, api, bindingStore, diff --git a/extensions/codex/src/app-server/config-options.ts b/extensions/codex/src/app-server/config-options.ts index a010bd0a27d0..7bdef7c66de1 100644 --- a/extensions/codex/src/app-server/config-options.ts +++ b/extensions/codex/src/app-server/config-options.ts @@ -41,10 +41,7 @@ import { assertCodexAppServerCommandHasNoInlineArgs, readCodexPluginConfig, } from "./config-parsing.js"; -import { - parseAllowedApprovalPoliciesFromCodexRequirements, - readCodexRequirementsToml, -} from "./config-requirements.js"; +import { parseCodexRequirementsPolicy, readCodexRequirementsToml } from "./config-requirements.js"; import { canUseCodexModelBackedApprovalsReviewerForModel, codexConfigEnablesNativeComputerUse, @@ -209,7 +206,7 @@ export function createCodexAppServerConfig({ if ( forcePerCommandApprovals && requirementsToml && - parseAllowedApprovalPoliciesFromCodexRequirements(requirementsToml)?.has("untrusted") === + parseCodexRequirementsPolicy(requirementsToml).allowedApprovalPolicies?.has("untrusted") === false ) { throw new Error("tools.exec.ask=always requires Codex app-server per-command approvals"); diff --git a/extensions/codex/src/app-server/config-requirements.ts b/extensions/codex/src/app-server/config-requirements.ts index e34bbbaad62b..aa9d512dd256 100644 --- a/extensions/codex/src/app-server/config-requirements.ts +++ b/extensions/codex/src/app-server/config-requirements.ts @@ -39,40 +39,24 @@ function resolveCodexRequirementsPath(env: NodeJS.ProcessEnv, platform: NodeJS.P return UNIX_CODEX_REQUIREMENTS_PATH; } -export function parseAllowedSandboxModesFromCodexRequirements( - content: string, - hostName: string, -): Set | undefined { - const requirements = parseCodexRequirements(content); - const remoteSandboxModes = parseMatchingRemoteSandboxModesFromCodexRequirements( - requirements, - hostName, - ); - if (remoteSandboxModes !== undefined) { - return remoteSandboxModes; - } - return parseRequirementsValues( - requirements?.allowed_sandbox_modes, - normalizeRequirementsSandboxMode, - ); -} - -export function parseAllowedApprovalPoliciesFromCodexRequirements( - content: string, -): Set | undefined { - return parseRequirementsValues( - parseCodexRequirements(content)?.allowed_approval_policies, - normalizeRequirementsApprovalPolicy, - ); -} - -export function parseAllowedApprovalsReviewersFromCodexRequirements( - content: string, -): Set | undefined { - return parseRequirementsValues( - parseCodexRequirements(content)?.allowed_approvals_reviewers, - (value) => resolveApprovalsReviewer(value.trim().toLowerCase()), - ); +export function parseCodexRequirementsPolicy(content: string | undefined, hostName = "") { + const requirements = content === undefined ? undefined : parseCodexRequirements(content); + return { + allowedSandboxModes: + parseMatchingRemoteSandboxModesFromCodexRequirements(requirements, hostName) ?? + parseRequirementsValues( + requirements?.allowed_sandbox_modes, + normalizeRequirementsSandboxMode, + ), + allowedApprovalPolicies: parseRequirementsValues( + requirements?.allowed_approval_policies, + normalizeRequirementsApprovalPolicy, + ), + allowedApprovalsReviewers: parseRequirementsValues( + requirements?.allowed_approvals_reviewers, + (value) => resolveApprovalsReviewer(value.trim().toLowerCase()), + ), + }; } function parseMatchingRemoteSandboxModesFromCodexRequirements( diff --git a/extensions/codex/src/app-server/config-reviewer-policy.ts b/extensions/codex/src/app-server/config-reviewer-policy.ts index 3eb34ef1a555..a6a230bb5cce 100644 --- a/extensions/codex/src/app-server/config-reviewer-policy.ts +++ b/extensions/codex/src/app-server/config-reviewer-policy.ts @@ -402,12 +402,8 @@ function isNativeReviewerBaseUrl(value: unknown, hostname: string): boolean { if (typeof value !== "string" || !value.trim()) { return true; } - try { - const url = new URL(value); - return url.protocol === "https:" && url.hostname.toLowerCase() === hostname; - } catch { - return false; - } + const url = URL.parse(value); + return url?.protocol === "https:" && url.hostname.toLowerCase() === hostname; } function normalizeCodexModelBackedReviewerPolicyProvider(provider: string): string { diff --git a/extensions/codex/src/app-server/config-security.ts b/extensions/codex/src/app-server/config-security.ts index 211fdb9077e7..3d3187d60dd4 100644 --- a/extensions/codex/src/app-server/config-security.ts +++ b/extensions/codex/src/app-server/config-security.ts @@ -16,9 +16,7 @@ import { selectGuardianSandbox } from "./config-exec-policy.js"; import { DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX } from "./config-parsing.js"; import { fingerprintCodexPolicy } from "./config-policy-json.js"; import { - parseAllowedApprovalPoliciesFromCodexRequirements, - parseAllowedApprovalsReviewersFromCodexRequirements, - parseAllowedSandboxModesFromCodexRequirements, + parseCodexRequirementsPolicy, readCodexRequirementsToml, selectGuardianApprovalPolicy, selectGuardianApprovalsReviewer, @@ -246,19 +244,11 @@ export function resolveDefaultCodexAppServerPolicy(params: { if (content === undefined && !params.forceGuardian) { return { mode: "yolo", dangerFullAccessAllowed: true }; } - const allowedSandboxModes = - content === undefined - ? undefined - : parseAllowedSandboxModesFromCodexRequirements( - content, - readNonEmptyString(params.hostName) ?? readHostName(), - ); - const allowedApprovalPolicies = - content === undefined ? undefined : parseAllowedApprovalPoliciesFromCodexRequirements(content); - const allowedApprovalsReviewers = - content === undefined - ? undefined - : parseAllowedApprovalsReviewersFromCodexRequirements(content); + const { allowedSandboxModes, allowedApprovalPolicies, allowedApprovalsReviewers } = + parseCodexRequirementsPolicy( + content, + content === undefined ? undefined : (readNonEmptyString(params.hostName) ?? readHostName()), + ); const yoloSandboxAllowed = allowedSandboxModes === undefined || allowedSandboxModes.has("danger-full-access"); const yoloApprovalAllowed = diff --git a/extensions/codex/src/app-server/context-engine-projection.ts b/extensions/codex/src/app-server/context-engine-projection.ts index db8be4bff564..6fa3a353fee6 100644 --- a/extensions/codex/src/app-server/context-engine-projection.ts +++ b/extensions/codex/src/app-server/context-engine-projection.ts @@ -9,6 +9,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { ImageContent } from "openclaw/plugin-sdk/llm"; import { redactSensitiveFieldValue, redactToolPayloadText } from "openclaw/plugin-sdk/logging-core"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; type CodexContextProjection = { @@ -168,11 +169,8 @@ export function resolveCodexContextEngineProjectionMaxChars(params: { contextTokenBudget?: number; reserveTokens?: number; }): number { - const contextTokenBudget = - typeof params.contextTokenBudget === "number" && Number.isFinite(params.contextTokenBudget) - ? Math.floor(params.contextTokenBudget) - : undefined; - if (!contextTokenBudget || contextTokenBudget <= 0) { + const contextTokenBudget = Math.floor(asFiniteNumber(params.contextTokenBudget) ?? 0); + if (contextTokenBudget <= 0) { return DEFAULT_RENDERED_CONTEXT_CHARS; } const scaledChars = @@ -244,11 +242,8 @@ export function resolveCodexContinuityProjectionMaxChars(params: { contextTokenBudget?: number; calibration?: CodexContinuityCalibration; }): number { - const contextTokenBudget = - typeof params.contextTokenBudget === "number" && Number.isFinite(params.contextTokenBudget) - ? Math.floor(params.contextTokenBudget) - : undefined; - if (!contextTokenBudget || contextTokenBudget <= 0) { + const contextTokenBudget = Math.floor(asFiniteNumber(params.contextTokenBudget) ?? 0); + if (contextTokenBudget <= 0) { return DEFAULT_RENDERED_CONTEXT_CHARS; } const continuityBudgetTokens = resolveProjectionPromptBudgetTokens({ diff --git a/extensions/codex/src/app-server/dynamic-tool-construction-plan.ts b/extensions/codex/src/app-server/dynamic-tool-construction-plan.ts index ca2b399de4f8..329bcc6d3394 100644 --- a/extensions/codex/src/app-server/dynamic-tool-construction-plan.ts +++ b/extensions/codex/src/app-server/dynamic-tool-construction-plan.ts @@ -1,5 +1,5 @@ import type { resolveSandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { isCodexRemoteExecPlacementSandbox } from "./config.js"; +import { isCodexPairedNodeRemoteExecPlacementSandbox } from "./config.js"; type OpenClawCodingToolsOptions = NonNullable< Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0] @@ -12,13 +12,7 @@ export function resolveCodexToolConstructionPlan( nativeToolSurfaceEnabled: boolean | undefined, requireWorkspaceOnly: boolean | undefined, ): OpenClawCodingToolsOptions["toolConstructionPlan"] { - if ( - !isCodexRemoteExecPlacementSandbox(sandbox) || - sandbox?.backendId !== "node" || - !("placementNodeId" in sandbox) || - typeof sandbox.placementNodeId !== "string" || - !sandbox.placementNodeId - ) { + if (!isCodexPairedNodeRemoteExecPlacementSandbox(sandbox) || sandbox?.backendId !== "node") { return requireWorkspaceOnly ? { includeBaseCodingTools: true, diff --git a/extensions/codex/src/app-server/event-projector-assistant.ts b/extensions/codex/src/app-server/event-projector-assistant.ts index 5f051126ff80..9308569b2d91 100644 --- a/extensions/codex/src/app-server/event-projector-assistant.ts +++ b/extensions/codex/src/app-server/event-projector-assistant.ts @@ -335,7 +335,7 @@ export class CodexAssistantProjection { if (recoveredAudible.length > 0) { return recoveredAudible.slice(-1); } - const recovered = this.resolveFinalAssistantTextItem()?.text; + const recovered = this.resolveFinalAssistantText(); return recovered ? [recovered] : []; } @@ -453,17 +453,12 @@ export class CodexAssistantProjection { } hasAssistantItemTextForSynthesis(): boolean { - for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) { - const itemId = this.assistantItemOrder[i]; - if (!itemId || this.isNonTerminalAssistantItem(itemId)) { - continue; - } - const text = this.assistantTextByItem.get(itemId); - if (text && text.length > 0) { - return true; - } - } - return false; + return this.assistantItemOrder.some( + (itemId) => + Boolean(itemId) && + !this.isNonTerminalAssistantItem(itemId) && + Boolean(this.assistantTextByItem.get(itemId)), + ); } createCurrentAttemptAssistantMessage( @@ -629,7 +624,7 @@ export class CodexAssistantProjection { this.supersedeVisibleAnswerCandidate(); } - private resolveFinalAssistantTextItem(): { itemId: string; text: string } | undefined { + private resolveFinalAssistantText(): string | undefined { for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) { const itemId = this.assistantItemOrder[i]; if (!itemId) { @@ -640,7 +635,7 @@ export class CodexAssistantProjection { continue; } if (text && !this.isToolProgressEchoText(itemId, text)) { - return { itemId, text }; + return text; } } return undefined; diff --git a/extensions/codex/src/app-server/inference-dispatch.ts b/extensions/codex/src/app-server/inference-dispatch.ts index 353aff05c525..78ca8941da92 100644 --- a/extensions/codex/src/app-server/inference-dispatch.ts +++ b/extensions/codex/src/app-server/inference-dispatch.ts @@ -2,7 +2,6 @@ import { isUtf8 } from "node:buffer"; import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; import { promisify } from "node:util"; import { zstdCompress, zstdDecompress } from "node:zlib"; -import type { RawData } from "openclaw/plugin-sdk/websocket-runtime"; import type { CodexAppServerClient } from "./client.js"; import type { createCodexInferenceContext } from "./inference-context.js"; import { readCodexInferenceMetadata, type CodexInferenceMetadata } from "./inference-metadata.js"; @@ -393,14 +392,6 @@ export async function readProxyBody(stream: IncomingMessage, maxBytes: number): return Buffer.concat(chunks); } -export function readProxyWebSocketBody(data: RawData): Buffer { - return Array.isArray(data) - ? Buffer.concat(data) - : Buffer.isBuffer(data) - ? data - : Buffer.from(data); -} - export function isTerminalResponse(bytes: Buffer): boolean { try { const event: unknown = JSON.parse(bytes.toString("utf8")); diff --git a/extensions/codex/src/app-server/inference-proxy.ts b/extensions/codex/src/app-server/inference-proxy.ts index f8a08587b997..a9e60a72fcb5 100644 --- a/extensions/codex/src/app-server/inference-proxy.ts +++ b/extensions/codex/src/app-server/inference-proxy.ts @@ -21,7 +21,6 @@ import { createCodexInferenceDispatch, isTerminalResponse, readProxyBody, - readProxyWebSocketBody, type CodexInferenceModelExecution, type CodexInferenceModelRequest, } from "./inference-dispatch.js"; @@ -38,6 +37,7 @@ import { MAX_UPLOADS, } from "./inference-upload.js"; import type { CodexResponsesOAuth } from "./responses-oauth.js"; +import { codexWebSocketDataToBuffer } from "./websocket-data.js"; const MAX_ERROR_BODY_BYTES = 1024 * 1024; const MAX_WEBSOCKETS = 64; @@ -618,7 +618,7 @@ export async function createCodexInferenceProxy(params: { throw new Error(FAILURE); } prepared = await prepare( - readProxyWebSocketBody(data), + codexWebSocketDataToBuffer(data), sampling, path, req.headers, @@ -656,14 +656,14 @@ export async function createCodexInferenceProxy(params: { remote!.on("message", (data: RawData, binary: boolean) => { if ( accepted.readyState !== WebSocket.OPEN || - accepted.bufferedAmount + readProxyWebSocketBody(data).length > MAX_BODY_BYTES + accepted.bufferedAmount + codexWebSocketDataToBuffer(data).length > MAX_BODY_BYTES ) { close(); return; } // Upload completion does not prove response quiescence. Only a // delivered terminal event makes this retained transport reclaimable. - const terminal = !binary && isTerminalResponse(readProxyWebSocketBody(data)); + const terminal = !binary && isTerminalResponse(codexWebSocketDataToBuffer(data)); const releaseCompletedFrame = terminal ? settleFrame : undefined; accepted.send(data, { binary }, (error) => { if (error) { diff --git a/extensions/codex/src/app-server/run-attempt-runtime.ts b/extensions/codex/src/app-server/run-attempt-runtime.ts index 794903ffb889..950c4c935508 100644 --- a/extensions/codex/src/app-server/run-attempt-runtime.ts +++ b/extensions/codex/src/app-server/run-attempt-runtime.ts @@ -39,14 +39,6 @@ import { fingerprintJsonObject } from "./thread-fingerprints.js"; import { resolveCodexAppServerThreadModelSelection } from "./thread-lifecycle.js"; import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js"; -function resolveCodexAttemptBundleManifestRegistry( - preparedModelRuntime: EmbeddedRunAttemptParams["preparedModelRuntime"], -) { - const metadataSnapshot = preparedModelRuntime?.metadataSnapshot; - // Scoped snapshots are partial views and cannot replace complete bundle discovery. - return metadataSnapshot?.pluginIds === undefined ? metadataSnapshot?.manifestRegistry : undefined; -} - export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnection) { const { params, @@ -170,9 +162,10 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect agentId: sessionAgentId, toolOverrides: params.toolOverrides, }); - const bundleManifestRegistry = resolveCodexAttemptBundleManifestRegistry( - params.preparedModelRuntime, - ); + const metadataSnapshot = params.preparedModelRuntime?.metadataSnapshot; + // Scoped snapshots are partial views and cannot replace complete bundle discovery. + const bundleManifestRegistry = + metadataSnapshot?.pluginIds === undefined ? metadataSnapshot?.manifestRegistry : undefined; const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({ workspaceDir: effectiveWorkspace, agentId: sessionAgentId, diff --git a/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts b/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts index 986cd2830fb0..5330dfb6ec24 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts @@ -6,6 +6,7 @@ import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { RawData, WebSocket } from "ws"; import type { CodexNodeExecServerLease } from "./sandbox-exec-server/types.js"; +import { codexWebSocketDataToBuffer } from "./websocket-data.js"; const CODEX_NODE_EXEC_SERVER_MAX_MESSAGE_BYTES = 64 * 1024 * 1024; const CODEX_NODE_EXEC_SERVER_MAX_FAILURE_DETAIL_CHARS = 240; @@ -153,13 +154,7 @@ function sendCodexExecServerFrame(socket: WebSocket, frame: Buffer): Promise CODEX_NODE_EXEC_SERVER_MAX_MESSAGE_BYTES) { throw new RangeError("Codex exec-server message exceeds its 64 MiB limit."); } @@ -223,10 +218,8 @@ function rejectCredentialedCodexNodeHttpRequest( } function hasCredentialedCodexNodeHttpUrl(value: string): boolean { - let url: URL; - try { - url = new URL(value); - } catch { + const url = URL.parse(value); + if (!url) { throw new Error("Codex http/request URL must be valid."); } if (url.username || url.password || hasSensitiveCodexNodeText(value)) { @@ -495,13 +488,9 @@ function sanitizeCodexExecServerEnvironment( if (typeof value !== "string") { throw new Error(`Codex process/start ${key} values must be strings.`); } - try { - const url = new URL(value); - if (url.username || url.password) { - continue; - } - } catch { - // Ordinary environment values need not be URLs. + const url = URL.parse(value); + if (url?.username || url?.password) { + continue; } values[name] = value; } diff --git a/extensions/codex/src/app-server/sandbox-exec-server.ts b/extensions/codex/src/app-server/sandbox-exec-server.ts index 93f1e6e12021..2f9d81593627 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server.ts @@ -28,6 +28,7 @@ import type { OpenClawLeasedExecServer, OpenClawNodeExecServer, } from "./sandbox-exec-server/types.js"; +import { codexWebSocketDataToBuffer } from "./websocket-data.js"; /** Codex environment metadata registered for one sandbox exec-server lease. */ export type CodexSandboxExecEnvironment = { @@ -153,16 +154,16 @@ function canExposeLocalExecServerToAppServer( if (typeof startOptions.url !== "string") { return false; } - try { - const host = new URL(startOptions.url).hostname.toLowerCase(); - const ipHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - if (host === "localhost" || ipHost === "::1") { - return true; - } - return isIP(ipHost) === 4 && ipHost.split(".")[0] === "127"; - } catch { + const host = URL.parse(startOptions.url)?.hostname.toLowerCase(); + if (!host) { return false; } + const ipHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + return ( + host === "localhost" || + ipHost === "::1" || + (isIP(ipHost) === 4 && ipHost.split(".")[0] === "127") + ); } async function acquireOpenClawExecServer(params: { @@ -514,10 +515,5 @@ function handleExecServerSocketError(error: unknown): void { } async function handleMessage(session: CodexSandboxExecSession, data: RawData): Promise { - const buffer = Array.isArray(data) - ? Buffer.concat(data) - : Buffer.isBuffer(data) - ? data - : Buffer.from(data); - await session.handleRequest(parseRequest(buffer.toString("utf8"))); + await session.handleRequest(parseRequest(codexWebSocketDataToBuffer(data).toString("utf8"))); } diff --git a/extensions/codex/src/app-server/session-permission-policy.ts b/extensions/codex/src/app-server/session-permission-policy.ts index c0051b9ed930..446b08ea8b39 100644 --- a/extensions/codex/src/app-server/session-permission-policy.ts +++ b/extensions/codex/src/app-server/session-permission-policy.ts @@ -9,9 +9,7 @@ import type { } from "./config-contracts.js"; import { selectGuardianSandbox } from "./config-exec-policy.js"; import { - parseAllowedApprovalPoliciesFromCodexRequirements, - parseAllowedApprovalsReviewersFromCodexRequirements, - parseAllowedSandboxModesFromCodexRequirements, + parseCodexRequirementsPolicy, selectGuardianApprovalPolicy, selectGuardianApprovalsReviewer, selectUserApprovalsReviewer, @@ -66,17 +64,14 @@ function tupleForMode( function requirementsAllowTuple( tuple: CodexSessionPermissionTuple, - allowed: { - sandboxes: Set | undefined; - approvalPolicies: Set | undefined; - reviewers: Set | undefined; - }, + allowed: ReturnType, ): boolean { return ( - (allowed.sandboxes === undefined || allowed.sandboxes.has(tuple.sandbox)) && - (allowed.approvalPolicies === undefined || - allowed.approvalPolicies.has(tuple.approvalPolicy)) && - (allowed.reviewers === undefined || allowed.reviewers.has(tuple.approvalsReviewer)) + (allowed.allowedSandboxModes === undefined || allowed.allowedSandboxModes.has(tuple.sandbox)) && + (allowed.allowedApprovalPolicies === undefined || + allowed.allowedApprovalPolicies.has(tuple.approvalPolicy)) && + (allowed.allowedApprovalsReviewers === undefined || + allowed.allowedApprovalsReviewers.has(tuple.approvalsReviewer)) ); } @@ -113,20 +108,16 @@ function clampSessionPermissionTuple(params: { if (!params.requirementsToml) { return params.requested; } - const allowed = { - sandboxes: parseAllowedSandboxModesFromCodexRequirements( - params.requirementsToml, - params.hostName ?? readHostName(), - ), - approvalPolicies: parseAllowedApprovalPoliciesFromCodexRequirements(params.requirementsToml), - reviewers: parseAllowedApprovalsReviewersFromCodexRequirements(params.requirementsToml), - }; + const allowed = parseCodexRequirementsPolicy( + params.requirementsToml, + params.hostName ?? readHostName(), + ); if (requirementsAllowTuple(params.requested, allowed)) { return params.requested; } if ( params.requested.approvalPolicy === "untrusted" && - allowed.approvalPolicies?.has("untrusted") === false + allowed.allowedApprovalPolicies?.has("untrusted") === false ) { throw new Error("tools.exec.ask=always requires Codex app-server per-command approvals"); } @@ -144,7 +135,7 @@ function clampSessionPermissionTuple(params: { (["read-only", "workspace-write", "danger-full-access"] as const).filter( (sandbox) => sandboxAuthority[sandbox] <= sandboxAuthority[params.requested.sandbox] && - (allowed.sandboxes === undefined || allowed.sandboxes.has(sandbox)), + (allowed.allowedSandboxModes === undefined || allowed.allowedSandboxModes.has(sandbox)), ), ); if (allowedSandboxes.size === 0) { @@ -158,12 +149,12 @@ function clampSessionPermissionTuple(params: { params.requested.approvalPolicy === "untrusted" ? "untrusted" : selectGuardianApprovalPolicy( - allowed.approvalPolicies, + allowed.allowedApprovalPolicies, userReviewRequired ? "ask" : "auto", ), approvalsReviewer: userReviewRequired - ? selectUserApprovalsReviewer(allowed.reviewers) - : selectGuardianApprovalsReviewer(allowed.reviewers, "auto"), + ? selectUserApprovalsReviewer(allowed.allowedApprovalsReviewers) + : selectGuardianApprovalsReviewer(allowed.allowedApprovalsReviewers, "auto"), }; } diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index f7114b3688f6..89c17bbf6303 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -503,7 +503,9 @@ export async function runCodexAppServerSideQuestion( }); } if (request.method === "item/tool/requestUserInput") { - return isSideUserInputRequest(request.params, childThreadId, turnId) + return isJsonObject(request.params) && + request.params.threadId === childThreadId && + request.params.turnId === turnId ? { answers: {} } : undefined; } @@ -1102,14 +1104,6 @@ async function createCodexSideToolBridge(input: { }; } -function isSideUserInputRequest( - value: JsonValue | undefined, - threadId: string, - turnId: string, -): boolean { - return isJsonObject(value) && value.threadId === threadId && value.turnId === turnId; -} - async function forkCodexSideThread( client: CodexAppServerClient, params: CodexThreadForkParams, diff --git a/extensions/codex/src/app-server/transport-websocket.ts b/extensions/codex/src/app-server/transport-websocket.ts index d39fd8876c22..ef4e76fcf035 100644 --- a/extensions/codex/src/app-server/transport-websocket.ts +++ b/extensions/codex/src/app-server/transport-websocket.ts @@ -7,9 +7,10 @@ import net from "node:net"; import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; -import { type ClientOptions, type RawData, WebSocket } from "openclaw/plugin-sdk/websocket-runtime"; +import { type ClientOptions, WebSocket } from "openclaw/plugin-sdk/websocket-runtime"; import { resolveCodexAppServerUserHomeDir, type CodexAppServerStartOptions } from "./config.js"; import type { CodexAppServerTransport } from "./transport.js"; +import { codexWebSocketDataToBuffer } from "./websocket-data.js"; const WEBSOCKET_HANDSHAKE_TIMEOUT_MS = 10_000; const WEBSOCKET_PING_INTERVAL_MS = 20_000; @@ -193,7 +194,7 @@ export function createWebSocketTransport( if (options.transport === "websocket") { recordConnectionActivity(); } - const frame = websocketFrameToBuffer(data); + const frame = codexWebSocketDataToBuffer(data); const writable = stdout.write(frame); const delimited = frame.at(-1) === 10 || stdout.write(Buffer.from("\n")); if (!writable || !delimited) { @@ -290,13 +291,3 @@ export function resolveCodexAppServerUnixSocketPath( ) ); } - -function websocketFrameToBuffer(data: RawData): Buffer { - if (Buffer.isBuffer(data)) { - return data; - } - if (Array.isArray(data)) { - return Buffer.concat(data); - } - return Buffer.from(data); -} diff --git a/extensions/codex/src/app-server/turn-router.ts b/extensions/codex/src/app-server/turn-router.ts index 7a46b3e2ba57..fa7814237ee6 100644 --- a/extensions/codex/src/app-server/turn-router.ts +++ b/extensions/codex/src/app-server/turn-router.ts @@ -150,15 +150,12 @@ class ClientTurnRouter implements CodexAppServerTurnRouter { private closeError?: Error; hasSiblingWork(threadId: string): boolean { - for (const routedThreadId of this.routes.keys()) { - if (routedThreadId !== threadId) { - return true; - } - } // A released route can still be waiting for native interruption to settle. - for (const watchedThreadId of this.nativeTurnCompletionWatchers.keys()) { - if (watchedThreadId !== threadId) { - return true; + for (const threads of [this.routes, this.nativeTurnCompletionWatchers]) { + for (const siblingThreadId of threads.keys()) { + if (siblingThreadId !== threadId) { + return true; + } } } return false; diff --git a/extensions/codex/src/app-server/websocket-data.ts b/extensions/codex/src/app-server/websocket-data.ts new file mode 100644 index 000000000000..6cf03cfa2314 --- /dev/null +++ b/extensions/codex/src/app-server/websocket-data.ts @@ -0,0 +1,11 @@ +import type { RawData } from "openclaw/plugin-sdk/websocket-runtime"; + +export function codexWebSocketDataToBuffer(data: RawData | Uint8Array): Buffer { + return Array.isArray(data) + ? Buffer.concat(data) + : Buffer.isBuffer(data) + ? data + : data instanceof Uint8Array + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) + : Buffer.from(data); +} diff --git a/extensions/codex/src/command-account.ts b/extensions/codex/src/command-account.ts index c61a610c69d4..8a473a24281b 100644 --- a/extensions/codex/src/command-account.ts +++ b/extensions/codex/src/command-account.ts @@ -269,13 +269,7 @@ function formatProfileKind(credential: AuthProfileCredential | undefined): strin if (!credential) { return "credential"; } - if (isChatGptSubscriptionProfile(credential)) { - return "ChatGPT subscription"; - } - if (credential.type === "api_key") { - return "API key"; - } - return "credential"; + return credential.type === "api_key" ? "API key" : "ChatGPT subscription"; } function formatProfileLabel( diff --git a/extensions/codex/src/command-diagnostics-support.ts b/extensions/codex/src/command-diagnostics-support.ts index 5e0a141ce8f4..ed664a53e60e 100644 --- a/extensions/codex/src/command-diagnostics-support.ts +++ b/extensions/codex/src/command-diagnostics-support.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -382,13 +383,7 @@ function recordBoundedCodexDiagnosticsCooldown( now: number, ): void { if (!map.has(key)) { - while (map.size >= maxSize) { - const oldestKey = map.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - map.delete(oldestKey); - } + pruneMapToMaxSize(map, maxSize - 1); } map.set(key, now); } diff --git a/extensions/codex/src/command-plugin-app-links.ts b/extensions/codex/src/command-plugin-app-links.ts index c0247e7c82d7..297ab8673ec9 100644 --- a/extensions/codex/src/command-plugin-app-links.ts +++ b/extensions/codex/src/command-plugin-app-links.ts @@ -74,20 +74,17 @@ function safeCodexAppLink(value: string | null): string | undefined { if (!value || value.length > 2048 || /[\s\p{Cc}\p{Cf}<>]/u.test(value)) { return undefined; } - try { - const url = new URL(value); - // Match Codex's hosted app-auth destinations; never send sign-in to a - // metadata-supplied lookalike host or rewrite a staging origin to production. - const host = url.hostname; - const hosted = - host === "chatgpt.com" || - host === "chatgpt-staging.com" || - host.endsWith(".chatgpt.com") || - host.endsWith(".chatgpt-staging.com"); - return url.protocol === "https:" && !url.username && !url.password && hosted - ? value - : undefined; - } catch { + const url = URL.parse(value); + if (!url) { return undefined; } + // Match Codex's hosted app-auth destinations; never send sign-in to a + // metadata-supplied lookalike host or rewrite a staging origin to production. + const host = url.hostname; + const hosted = + host === "chatgpt.com" || + host === "chatgpt-staging.com" || + host.endsWith(".chatgpt.com") || + host.endsWith(".chatgpt-staging.com"); + return url.protocol === "https:" && !url.username && !url.password && hosted ? value : undefined; } diff --git a/extensions/codex/src/migration/source.ts b/extensions/codex/src/migration/source.ts index 4bd7cfc85725..6f2c35884e0c 100644 --- a/extensions/codex/src/migration/source.ts +++ b/extensions/codex/src/migration/source.ts @@ -325,15 +325,9 @@ async function withPluginMigrationEligibility(params: { const appInfoById = new Map(snapshot.apps.map((app) => [app.id, app] as const)); const installedAppsById = new Map(snapshot.installedApps.map((app) => [app.id, app] as const)); for (const { plugin, apps: declaredApps } of pending) { - const apps = declaredApps - .map((app) => - sourcePluginAppFactWithInventory( - app, - appInfoById.get(app.id), - installedAppsById.get(app.id), - ), - ) - .toSorted((left, right) => left.id.localeCompare(right.id)); + const apps = declaredApps.map((app) => + sourcePluginAppFactWithInventory(app, appInfoById.get(app.id), installedAppsById.get(app.id)), + ); const blockCode = migrationBlockCodeForApps(apps); if (!blockCode) { evaluated.push({ ...plugin, apps, migratable: true }); diff --git a/extensions/codex/src/session-catalog-list-operation.ts b/extensions/codex/src/session-catalog-list-operation.ts index 78f2f5d1c825..69eec61cc144 100644 --- a/extensions/codex/src/session-catalog-list-operation.ts +++ b/extensions/codex/src/session-catalog-list-operation.ts @@ -32,7 +32,6 @@ import type { CodexSessionCatalogHost, CodexSessionCatalogPage, CodexSessionCatalogParams, - CodexSessionCatalogResult, } from "./session-catalog-types.js"; import { CodexCatalogVisiblePage } from "./session-catalog-visible-page.js"; @@ -661,9 +660,3 @@ export async function runCatalogListInline( operation.close(); } } - -export async function listCodexSessionCatalog( - params: ListParams, -): Promise { - return { hosts: await runCatalogListInline(createCodexSessionCatalogListOperation(params)) }; -} diff --git a/extensions/codex/src/session-catalog-originator-peer.test.ts b/extensions/codex/src/session-catalog-originator-peer.test.ts index 288d7fefa943..e1364b683929 100644 --- a/extensions/codex/src/session-catalog-originator-peer.test.ts +++ b/extensions/codex/src/session-catalog-originator-peer.test.ts @@ -12,7 +12,10 @@ import { createCodexTestBindingStore } from "./app-server/session-binding.test-h import { clearSharedCodexAppServerClientAndWait } from "./app-server/shared-client.js"; import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js"; import { createCodexSessionCatalogControl } from "./session-catalog-control.js"; -import { listCodexSessionCatalog } from "./session-catalog-list-operation.js"; +import { + createCodexSessionCatalogListOperation, + runCatalogListInline, +} from "./session-catalog-list-operation.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -128,21 +131,23 @@ it("hydrates recorded originators through the protocol and serves excluded rows localHomes: [source], query: { hostIds: [source.hostId], limitPerHost: 1 }, sessionEntries: { entriesForAgent: () => [], entriesForCatalog: () => [] }, - } satisfies Parameters[0]; - const result = await listCodexSessionCatalog(listParams); + } satisfies Parameters[0]; + const result = await runCatalogListInline(createCodexSessionCatalogListOperation(listParams)); const openedRollouts = opening.mock.calls.filter( ([file]) => typeof file === "string" && rolloutPaths.has(file), ); - expect(result.hosts).toHaveLength(1); - expect(result.hosts[0]).toMatchObject({ connected: true, sessions: [] }); - expect(result.hosts[0]).not.toHaveProperty("nextCursor"); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ connected: true, sessions: [] }); + expect(result[0]).not.toHaveProperty("nextCursor"); expect(cursors).toEqual( Array.from({ length: 20 }, (_, index) => (index === 0 ? undefined : String(index))), ); expect(openedRollouts).toHaveLength(0); const nativeRequests = [...methods]; opening.mockClear(); - expect(await listCodexSessionCatalog(listParams)).toEqual(result); + expect(await runCatalogListInline(createCodexSessionCatalogListOperation(listParams))).toEqual( + result, + ); expect(methods).toEqual(nativeRequests); expect( opening.mock.calls.filter(([file]) => typeof file === "string" && rolloutPaths.has(file)), diff --git a/extensions/codex/src/session-catalog-types.ts b/extensions/codex/src/session-catalog-types.ts index 3d3035c90de6..f6684ad393cc 100644 --- a/extensions/codex/src/session-catalog-types.ts +++ b/extensions/codex/src/session-catalog-types.ts @@ -137,10 +137,6 @@ export type CodexSessionCatalogHost = { error?: CodexSessionCatalogError; }; -export type CodexSessionCatalogResult = { - hosts: CodexSessionCatalogHost[]; -}; - export type CodexSessionTranscriptPage = { hostId: string; label: string; diff --git a/extensions/codex/src/session-catalog.test-helpers.ts b/extensions/codex/src/session-catalog.test-helpers.ts index 469d11ca0a5c..70736b8cb20f 100644 --- a/extensions/codex/src/session-catalog.test-helpers.ts +++ b/extensions/codex/src/session-catalog.test-helpers.ts @@ -55,10 +55,17 @@ import { type CodexAppServerBindingStore, type CodexAppServerThreadBinding, } from "./app-server/session-binding.test-helpers.js"; +import { continueLocalCodexSession as continueLocalCodexSessionRuntime } from "./session-catalog-adoption.js"; +import { archiveLocalCodexSession } from "./session-catalog-archive.js"; import { createCodexCatalogHomeResolver as createCodexCatalogHomeResolverRuntime, type CodexCatalogHome, } from "./session-catalog-homes.js"; +import { + createCodexSessionCatalogListOperation, + runCatalogListInline, +} from "./session-catalog-list-operation.js"; +import { readCodexSessionTranscript as readCodexSessionTranscriptRuntime } from "./session-catalog-listing.js"; import { catalogError, CODEX_LOCAL_SESSION_HOST_ID } from "./session-catalog-parsing.js"; import { CODEX_TERMINAL_RESUME_COMMAND, @@ -69,7 +76,7 @@ import type { CodexSessionCatalogControlFactory, } from "./session-catalog-types.js"; import { - codexSessionCatalogRuntime, + registerCodexSessionCatalog as registerCodexSessionCatalogRuntime, createCodexSessionCatalogControl as createCodexSessionCatalogControlRuntime, createCodexSessionCatalogNodeHostCommands as createCodexSessionCatalogNodeHostCommandsRuntime, createCodexSessionCatalogNodeInvokePolicies, @@ -120,12 +127,6 @@ afterEach(async () => { process.env.PATH = originalPath; }); -const archiveLocalCodexSession = codexSessionCatalogRuntime.archiveLocal; -const continueLocalCodexSessionRuntime = codexSessionCatalogRuntime.continueLocal; -const listCodexSessionCatalogRuntime = codexSessionCatalogRuntime.list; -const readCodexSessionTranscriptRuntime = codexSessionCatalogRuntime.readTranscript; -const registerCodexSessionCatalogRuntime = codexSessionCatalogRuntime.register; - function createCodexSessionCatalogControlFactory( params: Omit< Parameters[0], @@ -189,15 +190,22 @@ function asControlFactory( }; } -export function listCodexSessionCatalog( - params: Omit[0], "control"> & { +export async function listCodexSessionCatalog( + params: Omit[0], "control"> & { control: | CodexSessionCatalogControl | CodexSessionCatalogControlFactory | CodexSessionCatalogControlFactoryStub; }, ) { - return listCodexSessionCatalogRuntime({ ...params, control: asControlFactory(params.control) }); + return { + hosts: await runCatalogListInline( + createCodexSessionCatalogListOperation({ + ...params, + control: asControlFactory(params.control), + }), + ), + }; } const catalogOwners = new WeakMap< diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index 4a3052df6fd6..ab345d72483e 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -17,7 +17,6 @@ import { import type { CodexCatalogHome } from "./session-catalog-homes.js"; import { createCodexSessionCatalogListOperation, - listCodexSessionCatalog, runCatalogListInline, } from "./session-catalog-list-operation.js"; import { readCodexSessionTranscript } from "./session-catalog-listing.js"; @@ -250,7 +249,7 @@ function mapCatalogListOperation( }; } -function registerCodexSessionCatalog(params: { +export function registerCodexSessionCatalog(params: { api: OpenClawPluginApi; bindingStore: CodexAppServerBindingStore; control: CodexSessionCatalogControlFactory; @@ -484,15 +483,6 @@ function registerCodexSessionCatalog(params: { params.api.registerSessionCatalog(provider); } -export const codexSessionCatalogRuntime = { - register: registerCodexSessionCatalog, - list: listCodexSessionCatalog, - readTranscript: readCodexSessionTranscript, - continueLocal: continueLocalCodexSession, - continueNode: continueNodeCodexSession, - archiveLocal: archiveLocalCodexSession, -}; - async function continueLocalCodexSession( ...args: Parameters ) { diff --git a/extensions/codex/src/supervision-tools.ts b/extensions/codex/src/supervision-tools.ts index 47011d66fe88..61e6c7e8216e 100644 --- a/extensions/codex/src/supervision-tools.ts +++ b/extensions/codex/src/supervision-tools.ts @@ -802,17 +802,16 @@ function redactEndpointUrl(value: string): string { if (value.startsWith("unix://")) { return "unix://"; } - try { - const url = new URL(value); - url.username = ""; - url.password = ""; - if (url.search) { - url.search = "?[redacted]"; - } - return url.toString(); - } catch { + const url = URL.parse(value); + if (!url) { return "[redacted]"; } + url.username = ""; + url.password = ""; + if (url.search) { + url.search = "?[redacted]"; + } + return url.toString(); } function endpointResult( diff --git a/extensions/deepinfra/index.ts b/extensions/deepinfra/index.ts index 5a3c5f2b3247..35388d3febb3 100644 --- a/extensions/deepinfra/index.ts +++ b/extensions/deepinfra/index.ts @@ -47,9 +47,6 @@ export default defineSingleProviderPluginEntry({ run: buildDeepInfraApiKeyCatalog, staticRun: async () => ({ provider: buildStaticDeepInfraProvider() }), }, - normalizeConfig: ({ providerConfig }) => providerConfig, - normalizeTransport: ({ api, baseUrl }) => - baseUrl === "https://api.deepinfra.com/v1/openai" ? { api, baseUrl } : undefined, ...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }), wrapStreamFn: (ctx) => { const thinkingLevel = isProxyReasoningUnsupported(ctx.modelId) diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index 65022bc680c8..2f65f62cc9c1 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -12,7 +12,10 @@ import { } from "openclaw/plugin-sdk/provider-http"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalObjectRecord, + filterStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveFirstGithubToken } from "./auth.js"; import { resolveGithubCopilotDomain } from "./domain.js"; import { COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS } from "./models.js"; @@ -109,9 +112,7 @@ async function discoverEmbeddingModels(params: { if (!id) { return []; } - const endpoints = Array.isArray(entry.supported_endpoints) - ? entry.supported_endpoints.filter((value): value is string => typeof value === "string") - : []; + const endpoints = filterStringEntries(entry.supported_endpoints); return endpoints.some((ep) => ep.includes("embeddings")) || /\bembedding/i.test(id) ? [id] : []; @@ -145,12 +146,8 @@ function pickBestModel(available: string[], userModel?: string): string { } return normalized; } - for (const preferred of PREFERRED_MODELS) { - if (available.includes(preferred)) { - return preferred; - } - } - const [firstAvailable] = available; + const firstAvailable = + PREFERRED_MODELS.find((preferred) => available.includes(preferred)) ?? available[0]; if (firstAvailable) { return firstAvailable; } diff --git a/extensions/github-copilot/runtime-auth.ts b/extensions/github-copilot/runtime-auth.ts index e0bfd6194b55..c367c0d48799 100644 --- a/extensions/github-copilot/runtime-auth.ts +++ b/extensions/github-copilot/runtime-auth.ts @@ -39,10 +39,8 @@ function parseCopilotApiBaseUrl(value: unknown, domain: string): string { if (typeof api !== "string" || !api.trim()) { throw new Error("GitHub Copilot user response has an invalid endpoints.api URL"); } - let url: URL; - try { - url = new URL(api); - } catch { + const url = URL.parse(api); + if (!url) { throw new Error("GitHub Copilot user response has an invalid endpoints.api URL"); } const host = url.hostname.toLowerCase(); diff --git a/extensions/github-copilot/starter-model.ts b/extensions/github-copilot/starter-model.ts index f513618d7cd1..d26742eb2a84 100644 --- a/extensions/github-copilot/starter-model.ts +++ b/extensions/github-copilot/starter-model.ts @@ -5,12 +5,7 @@ import { fetchCopilotModelCatalog, PROVIDER_ID, selectCopilotStarterModel } from import { resolveCopilotRuntimeAuth } from "./runtime-auth.js"; import { buildCopilotRuntimeHeaders } from "./runtime-identity.js"; -function preferredCopilotStarterModelId(): string { - const prefix = `${PROVIDER_ID}/`; - return DEFAULT_COPILOT_MODEL.startsWith(prefix) - ? DEFAULT_COPILOT_MODEL.slice(prefix.length) - : DEFAULT_COPILOT_MODEL; -} +const PREFERRED_COPILOT_STARTER_MODEL_ID = DEFAULT_COPILOT_MODEL.slice(PROVIDER_ID.length + 1); export async function resolveCopilotStarterModel(params: { githubToken: string; @@ -29,7 +24,7 @@ export async function resolveCopilotStarterModel(params: { baseUrl: auth.baseUrl, headers: buildCopilotRuntimeHeaders({ config: params.config }), }); - const selected = selectCopilotStarterModel(models, preferredCopilotStarterModelId()); + const selected = selectCopilotStarterModel(models, PREFERRED_COPILOT_STARTER_MODEL_ID); if (!selected) { throw new Error( "GitHub Copilot did not return an enabled, picker-visible chat model with streaming and tool-call support.", diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index 7d7a65672cd4..bae71073e2ab 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -16,6 +16,7 @@ import { StartSensitivity, TurnCoverage, } from "@google/genai"; +import { createDeferred } from "openclaw/plugin-sdk/concurrency-runtime"; import { resolveExpiresAtMsFromDurationMs, timestampMsToIsoString, @@ -469,18 +470,15 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge { if (this.connectAttempt) { return this.connectAttempt.promise; } - let cancel = () => {}; - const cancelled = new Promise((resolve) => { - cancel = resolve; - }); + const cancelled = createDeferred(); const attempt: GoogleLiveConnectionAttempt = { - promise: cancelled, - cancel, + promise: cancelled.promise, + cancel: cancelled.resolve, }; this.connectionOwner = attempt; this.connectAttempt = attempt; const connection = this.connectOwned(attempt); - attempt.promise = Promise.race([connection, cancelled]).finally(() => { + attempt.promise = Promise.race([connection, cancelled.promise]).finally(() => { if (this.connectAttempt === attempt) { this.connectAttempt = undefined; } diff --git a/extensions/google/speech-models.ts b/extensions/google/speech-models.ts index 6592a32a559a..e065c41b64a6 100644 --- a/extensions/google/speech-models.ts +++ b/extensions/google/speech-models.ts @@ -39,8 +39,7 @@ export function normalizeGoogleTtsModel(model: unknown): string { } export function isGoogleInteractionsTtsModel(model: string): boolean { - // SAFETY: widening a readonly literal tuple to readonly string[] so includes() accepts any model id. - return (GOOGLE_TTS_INTERACTIONS_MODELS as readonly string[]).includes(model); + return GOOGLE_TTS_INTERACTIONS_MODELS.some((candidate) => candidate === model); } export function assertSupportedGoogleTtsModel(model: string): void { diff --git a/extensions/google/speech-provider.ts b/extensions/google/speech-provider.ts index 8bba402232bf..58225df12105 100644 --- a/extensions/google/speech-provider.ts +++ b/extensions/google/speech-provider.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { SpeechDirectiveTokenParseContext, + SpeechDirectiveTokenParseResult, SpeechProviderConfig, SpeechProviderOverrides, SpeechProviderPlugin, @@ -110,11 +111,9 @@ function readGoogleTtsOverrides( }; } -function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): { - handled: boolean; - overrides?: SpeechProviderOverrides; - warnings?: string[]; -} { +function parseDirectiveToken( + ctx: SpeechDirectiveTokenParseContext, +): SpeechDirectiveTokenParseResult { switch (ctx.key) { case "voicename": case "voice_name": diff --git a/extensions/google/speech-synthesis.ts b/extensions/google/speech-synthesis.ts index f5f08b50f9c9..47b295b7b66b 100644 --- a/extensions/google/speech-synthesis.ts +++ b/extensions/google/speech-synthesis.ts @@ -76,21 +76,9 @@ function normalizePromptSectionText(value: string | undefined): string | undefin if (!trimmed) { return undefined; } - let sanitized = ""; - for (const char of trimmed) { - const code = char.charCodeAt(0); - if ( - (code >= 0 && code <= 8) || - code === 11 || - code === 12 || - (code >= 14 && code <= 31) || - code === 127 - ) { - continue; - } - sanitized += char; - } - return sanitized; + return trimmed.replace(/\p{Cc}/gu, (char) => + "\t\n\r".includes(char) || char.charCodeAt(0) > 127 ? char : "", + ); } export function isOpenClawGoogleAudioProfilePrompt(text: string): boolean { diff --git a/extensions/google/src/google-api-base-url.ts b/extensions/google/src/google-api-base-url.ts index 72646d9d5cf1..f4e27a46e72f 100644 --- a/extensions/google/src/google-api-base-url.ts +++ b/extensions/google/src/google-api-base-url.ts @@ -23,11 +23,6 @@ export function isOfficialGoogleAiStudioBaseUrl(baseUrl?: string | null): boolea ); } -function stripUrlUserInfo(url: URL): void { - url.username = ""; - url.password = ""; -} - const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com"; const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com"; const GOOGLE_VERTEX_MULTI_REGION_HOSTS = new Set([ @@ -61,7 +56,8 @@ export function normalizeGoogleApiBaseUrl(baseUrl?: string): string { } url.hash = ""; url.search = ""; - stripUrlUserInfo(url); + url.username = ""; + url.password = ""; if (isGoogleGenerativeAiUrl(url)) { const normalizedPath = trimTrailingSlashes(url.pathname || ""); url.pathname = normalizedPath || "/v1beta"; @@ -82,7 +78,6 @@ export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | u const normalized = normalizeGoogleApiBaseUrl(raw); const url = URL.parse(normalized); if (url) { - stripUrlUserInfo(url); if (isGoogleGenerativeAiUrl(url)) { url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta"; return trimTrailingSlashes(url.toString()); diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index 4adea81135fd..48c047376bb2 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -147,25 +147,14 @@ function resolveGoogleGeneratedVideoDownloadUrl(params: { if (!trimmed) { return undefined; } - let url: URL; - try { - url = new URL(trimmed); - } catch { - return undefined; - } - if (url.protocol !== "https:") { + const url = URL.parse(trimmed); + if (url?.protocol !== "https:") { return undefined; } const allowedOrigins = new Set(["https://generativelanguage.googleapis.com"]); - if (params.configuredBaseUrl) { - try { - const configuredOrigin = new URL(params.configuredBaseUrl).origin; - if (configuredOrigin.startsWith("https://")) { - allowedOrigins.add(configuredOrigin); - } - } catch { - // Ignore invalid configured origins; the request base URL is already normalized. - } + const configuredOrigin = URL.parse(params.configuredBaseUrl ?? "")?.origin; + if (configuredOrigin?.startsWith("https://")) { + allowedOrigins.add(configuredOrigin); } if (!allowedOrigins.has(url.origin)) { return undefined; diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index e1b162121764..2dd6fdfc2440 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -9,7 +9,7 @@ import { import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { readResponseTextPrefix } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asPositiveSafeInteger, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; import { mapLmstudioWireModels, @@ -161,10 +161,7 @@ export async function fetchLmstudioModels(params: { "LM Studio model list", "models", ); - const validModels = models.filter( - (model): model is LmstudioModelWire => - typeof model === "object" && model !== null && !Array.isArray(model), - ); + const validModels = models.filter(isRecord); if (models.length > 0 && validModels.length === 0) { throw new Error("LM Studio model list: malformed JSON response"); } diff --git a/extensions/lmstudio/src/models.ts b/extensions/lmstudio/src/models.ts index e8c758dc49c5..3d5fb8b455ed 100644 --- a/extensions/lmstudio/src/models.ts +++ b/extensions/lmstudio/src/models.ts @@ -302,21 +302,16 @@ export function resolveLmstudioServerBase(configuredBaseUrl?: string): string { const configured = configuredBaseUrl?.trim(); const resolved = configured || LMSTUDIO_DEFAULT_BASE_URL; const fetchableBaseUrl = toFetchableLmstudioBaseUrl(resolved); - try { - const parsed = new URL(fetchableBaseUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new TypeError(`Unsupported LM Studio protocol: ${parsed.protocol}`); - } + const parsed = URL.parse(fetchableBaseUrl); + if (parsed && (parsed.protocol === "http:" || parsed.protocol === "https:")) { const pathname = normalizeUrlPath(parsed.pathname); parsed.pathname = pathname.length > 0 ? pathname : "/"; parsed.search = ""; parsed.hash = ""; return parsed.toString().replace(/\/$/, ""); - } catch { - const trimmed = resolved.replace(/\/+$/, ""); - const normalized = normalizeUrlPath(trimmed); - return normalized.length > 0 ? normalized : LMSTUDIO_DEFAULT_BASE_URL; } + const normalized = normalizeUrlPath(resolved.replace(/\/+$/, "")); + return normalized.length > 0 ? normalized : LMSTUDIO_DEFAULT_BASE_URL; } /** Resolves LM Studio inference base URL and always appends /v1. */ diff --git a/extensions/lmstudio/src/provider-auth.ts b/extensions/lmstudio/src/provider-auth.ts index 6a7ed1f476f7..7fcd45b1ec12 100644 --- a/extensions/lmstudio/src/provider-auth.ts +++ b/extensions/lmstudio/src/provider-auth.ts @@ -19,15 +19,10 @@ export function resolveLmstudioProviderAuthMode( ): ModelProviderConfig["auth"] | undefined { const normalized = normalizeOptionalSecretInput(apiKey); if (normalized !== undefined) { - const trimmed = normalized.trim(); - if ( - !trimmed || - trimmed === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER || - trimmed === CUSTOM_LOCAL_AUTH_MARKER - ) { - return undefined; - } - return "api-key"; + return normalized === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER || + normalized === CUSTOM_LOCAL_AUTH_MARKER + ? undefined + : "api-key"; } return hasConfiguredSecretInput(apiKey) ? "api-key" : undefined; } diff --git a/extensions/lmstudio/src/setup-prompts.ts b/extensions/lmstudio/src/setup-prompts.ts index 5e8e878ed891..463e3b96e8d1 100644 --- a/extensions/lmstudio/src/setup-prompts.ts +++ b/extensions/lmstudio/src/setup-prompts.ts @@ -15,12 +15,8 @@ export function validateLmstudioSetupUrl(value: string | undefined): string | un } const invalidUrl = "Enter a valid HTTP or HTTPS URL without embedded credentials (e.g. http://localhost:1234)."; - try { - const parsed = new URL(toFetchableLmstudioBaseUrl(value.trim())); - // Discovery fetches reject credential-bearing URLs; catch them here instead. - const isHttp = parsed.protocol === "http:" || parsed.protocol === "https:"; - return isHttp && !parsed.username && !parsed.password ? undefined : invalidUrl; - } catch { - return invalidUrl; - } + const parsed = URL.parse(toFetchableLmstudioBaseUrl(value.trim())); + // Discovery fetches reject credential-bearing URLs; catch them here instead. + const isHttp = parsed?.protocol === "http:" || parsed?.protocol === "https:"; + return isHttp && !parsed.username && !parsed.password ? undefined : invalidUrl; } diff --git a/extensions/minimax/image-generation-provider.ts b/extensions/minimax/image-generation-provider.ts index f47c278d240c..0ecab5edf2c7 100644 --- a/extensions/minimax/image-generation-provider.ts +++ b/extensions/minimax/image-generation-provider.ts @@ -51,12 +51,8 @@ function isMinimaxCnHost(value: string | undefined): boolean { return false; } const candidate = /^[a-z][a-z\d+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`; - try { - const hostname = new URL(candidate).hostname.toLowerCase(); - return hostname === "minimaxi.com" || hostname.endsWith(".minimaxi.com"); - } catch { - return false; - } + const hostname = URL.parse(candidate)?.hostname.toLowerCase(); + return hostname === "minimaxi.com" || (hostname?.endsWith(".minimaxi.com") ?? false); } function resolveMinimaxImageBaseUrl( diff --git a/extensions/minimax/provider-catalog.ts b/extensions/minimax/provider-catalog.ts index 6448ffafcdb8..1cb04b54e47b 100644 --- a/extensions/minimax/provider-catalog.ts +++ b/extensions/minimax/provider-catalog.ts @@ -26,21 +26,15 @@ export function buildMinimaxModelDiscovery( } export function resolveMinimaxCatalogBaseUrl(env: NodeJS.ProcessEnv = process.env): string { - const rawHost = env.MINIMAX_API_HOST?.trim(); - if (!rawHost) { + const url = URL.parse(env.MINIMAX_API_HOST?.trim() ?? ""); + if (!url) { return MINIMAX_API_BASE_URL; } - - try { - const url = new URL(rawHost); - const basePath = url.pathname.replace(/\/+$/, ""); - if (basePath.endsWith("/anthropic")) { - return `${url.origin}${basePath}`; - } - return `${url.origin}/anthropic`; - } catch { - return MINIMAX_API_BASE_URL; + const basePath = url.pathname.replace(/\/+$/, ""); + if (basePath.endsWith("/anthropic")) { + return `${url.origin}${basePath}`; } + return `${url.origin}/anthropic`; } export function buildMinimaxProvider(env?: NodeJS.ProcessEnv): ModelProviderConfig { diff --git a/extensions/nvidia/provider-catalog.ts b/extensions/nvidia/provider-catalog.ts index 882ac669ba16..29a5ac30aca4 100644 --- a/extensions/nvidia/provider-catalog.ts +++ b/extensions/nvidia/provider-catalog.ts @@ -33,6 +33,8 @@ const FEATURED_MODEL_MAX_ID_LENGTH = 200; const FEATURED_MODEL_MAX_NAME_LENGTH = 200; const FEATURED_MODEL_MAX_CONTEXT_WINDOW = 10_000_000; const FEATURED_MODEL_MAX_OUTPUT_TOKENS = 1_000_000; +const INVALID_FEATURED_MODEL_ID_CHARS = new RegExp(String.raw`[\u0000-\u0020\u007f]`); +const INVALID_FEATURED_MODEL_NAME_CHARS = new RegExp(String.raw`[\u0000-\u001f\u007f]`); const FEATURED_MODEL_COST = { input: 0, output: 0, @@ -226,8 +228,8 @@ function parseNvidiaFeaturedModel(row: unknown): ModelDefinitionConfig | null { !name || !contextWindow || !maxTokens || - hasControlCharacter(id, true) || - hasControlCharacter(name) + INVALID_FEATURED_MODEL_ID_CHARS.test(id) || + INVALID_FEATURED_MODEL_NAME_CHARS.test(name) ) { return null; } @@ -244,13 +246,3 @@ function parseNvidiaFeaturedModel(row: unknown): ModelDefinitionConfig | null { }, }; } - -function hasControlCharacter(value: string, includeSpace = false): boolean { - for (const char of value) { - const code = char.charCodeAt(0); - if (code <= (includeSpace ? 32 : 31) || code === 127) { - return true; - } - } - return false; -} diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index 596bc7d020cb..815e45dc8eae 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -8,6 +8,7 @@ import type { } from "openclaw/plugin-sdk/image-generation"; import type { resolveClosestSize } from "openclaw/plugin-sdk/media-generation-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; +import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { AuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; import type { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; @@ -113,47 +114,29 @@ function resolveOpenAIImageTimeoutMs( ); } -function resolveOpenAIImageCount(count: number | undefined): number { - if (typeof count !== "number" || !Number.isFinite(count)) { - return 1; - } - return Math.max(1, Math.min(OPENAI_MAX_IMAGE_RESULTS, Math.trunc(count))); -} - function isPublicOpenAIImageBaseUrl(baseUrl: string): boolean { - const trimmed = baseUrl.trim(); - if (!trimmed) { - return false; - } - try { - const parsed = new URL(trimmed); - const pathName = parsed.pathname.replace(/\/+$/, ""); - return ( - parsed.protocol === "https:" && - parsed.hostname.toLowerCase() === "api.openai.com" && - parsed.port === "" && - parsed.username === "" && - parsed.password === "" && - parsed.search === "" && - parsed.hash === "" && - pathName === "/v1" - ); - } catch { + const parsed = URL.parse(baseUrl.trim()); + if (!parsed) { return false; } + const pathName = parsed.pathname.replace(/\/+$/, ""); + return ( + parsed.protocol === "https:" && + parsed.hostname.toLowerCase() === "api.openai.com" && + parsed.port === "" && + parsed.username === "" && + parsed.password === "" && + parsed.search === "" && + parsed.hash === "" && + pathName === "/v1" + ); } function isAzureOpenAIBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - try { - const hostname = new URL(trimmed).hostname.toLowerCase(); - return AZURE_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); - } catch { - return false; - } + const hostname = URL.parse(baseUrl?.trim() ?? "")?.hostname.toLowerCase(); + return ( + hostname !== undefined && AZURE_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) + ); } function resolveAzureApiVersion(): string { @@ -569,7 +552,7 @@ async function generateOpenAICodexImage(params: { const model = resolveOpenAIImageRequestModel(req, { allowTransparentDefaultReroute: true, }); - const count = resolveOpenAIImageCount(req.count); + const count = resolveIntegerOption(req.count, 1, { min: 1, max: OPENAI_MAX_IMAGE_RESULTS }); const sizeResolution = resolveOpenAIImageRequestSize( { model, @@ -847,7 +830,7 @@ export function buildOpenAIImageGenerationProvider( const model = resolveOpenAIImageRequestModel(req, { allowTransparentDefaultReroute: publicOpenAIBaseUrl, }); - const count = resolveOpenAIImageCount(req.count); + const count = resolveIntegerOption(req.count, 1, { min: 1, max: OPENAI_MAX_IMAGE_RESULTS }); const timeoutMs = resolveOpenAIImageTimeoutMs(req.timeoutMs, { isAzure }); const sizeResolution = isValidFlexibleOpenAIImageSize(model, req.size) ? { size: req.size } diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts index 9b88e85d115c..21ce2dbc414e 100644 --- a/extensions/openrouter/video-generation-provider.ts +++ b/extensions/openrouter/video-generation-provider.ts @@ -310,12 +310,8 @@ function resolveDeliverableOpenRouterVideoUrl(value: string | undefined): string if (!normalized) { return undefined; } - try { - const url = new URL(normalized); - return url.protocol === "https:" || url.protocol === "http:" ? normalized : undefined; - } catch { - return undefined; - } + const url = URL.parse(normalized); + return url?.protocol === "https:" || url?.protocol === "http:" ? normalized : undefined; } async function downloadOpenRouterVideo(params: { diff --git a/extensions/qwen/models.ts b/extensions/qwen/models.ts index da087c2be942..6924bbb4068f 100644 --- a/extensions/qwen/models.ts +++ b/extensions/qwen/models.ts @@ -93,15 +93,11 @@ export function isQwenCodingPlanBaseUrl(baseUrl: string | undefined): boolean { if (!trimmed) { return false; } - try { - const hostname = new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, ""); - return ( - hostname === "coding.dashscope.aliyuncs.com" || - hostname === "coding-intl.dashscope.aliyuncs.com" - ); - } catch { - return false; - } + const hostname = URL.parse(trimmed)?.hostname.toLowerCase().replace(/\.+$/, ""); + return ( + hostname === "coding.dashscope.aliyuncs.com" || + hostname === "coding-intl.dashscope.aliyuncs.com" + ); } export function isQwen36PlusSupportedBaseUrl(_baseUrl: string | undefined): boolean { diff --git a/extensions/stepfun/index.ts b/extensions/stepfun/index.ts index 2a1fbb5b7298..f2c90f0a52ba 100644 --- a/extensions/stepfun/index.ts +++ b/extensions/stepfun/index.ts @@ -56,16 +56,12 @@ function inferRegionFromBaseUrl(baseUrl: string | undefined): StepFunRegion | un if (!baseUrl) { return undefined; } - try { - const host = normalizeLowercaseStringOrEmpty(new URL(baseUrl).hostname); - if (host === "api.stepfun.com") { - return "cn"; - } - if (host === "api.stepfun.ai") { - return "intl"; - } - } catch { - return undefined; + const host = normalizeLowercaseStringOrEmpty(URL.parse(baseUrl)?.hostname); + if (host === "api.stepfun.com") { + return "cn"; + } + if (host === "api.stepfun.ai") { + return "intl"; } return undefined; } diff --git a/extensions/vercel-ai-gateway/models.ts b/extensions/vercel-ai-gateway/models.ts index ea58d8257d59..b41e77c84daf 100644 --- a/extensions/vercel-ai-gateway/models.ts +++ b/extensions/vercel-ai-gateway/models.ts @@ -85,16 +85,8 @@ const STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG: readonly StaticVercelGatewayModel[ ] as const; function toPerMillionCost(value: unknown): number { - const numeric = - typeof value === "number" - ? value - : typeof value === "string" - ? parseStrictFiniteNumber(value) - : undefined; - if (numeric === undefined || numeric < 0) { - return 0; - } - return numeric * 1_000_000; + const price = (parseStrictFiniteNumber(value) ?? 0) * 1_000_000; + return Number.isFinite(price) && price >= 0 ? price : 0; } function normalizeCost(value: unknown): ModelDefinitionConfig["cost"] { diff --git a/extensions/vercel-ai-gateway/provider-catalog.test.ts b/extensions/vercel-ai-gateway/provider-catalog.test.ts index ff3b976cc93d..b7fe0114009c 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.test.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.test.ts @@ -132,6 +132,44 @@ describe("vercel ai gateway provider catalog", () => { }); }); + it.each(["1e400", "1e308", '"1e308"'])( + "falls back from overflowing live prices (%s) while preserving valid rates", + async (price) => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + // Keep the wire number: JSON.stringify would turn numeric Infinity into null. + response: new Response( + `{"data":[ + {"id":"anthropic/claude-opus-4.6","pricing":{"input":${price}}}, + {"id":"custom/partly-priced","pricing":{"input":${price},"output":0.000001}}, + {"id":"custom/valid","pricing":{"input":0.000002,"output":"3.5e-6","input_cache_read":"0x10","input_cache_write":-0.000001}} + ]}`, + { headers: { "Content-Type": "application/json" } }, + ), + release, + finalUrl: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`, + }); + + const models = await discoverVercelAiGatewayModels({ discoveryMode: "strict" }); + + expect(models.map(({ id, cost }) => ({ id, cost }))).toEqual([ + { + id: "anthropic/claude-opus-4.6", + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, + { + id: "custom/partly-priced", + cost: { input: 0, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "custom/valid", + cost: { input: 2, output: 3.5, cacheRead: 0, cacheWrite: 0 }, + }, + ]); + expect(release).toHaveBeenCalledOnce(); + }, + ); + it("uses the trusted environment proxy for the official live catalog", async () => { const release = mockCatalog({ data: [{ id: "custom/live-model" }] }); diff --git a/extensions/xai/provider-routing.ts b/extensions/xai/provider-routing.ts index 7978627bccbd..6b6d9a7accc5 100644 --- a/extensions/xai/provider-routing.ts +++ b/extensions/xai/provider-routing.ts @@ -2,16 +2,12 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti import { XAI_BASE_URL } from "./model-definitions.js"; import { isXaiProviderId } from "./provider-id.js"; -const XAI_NATIVE_ENDPOINT_HOSTS = new Set(["api.x.ai"]); - function resolveHostname(value: string): string | undefined { return URL.parse(value)?.hostname.toLowerCase(); } function isXaiNativeEndpoint(baseUrl: unknown): boolean { - return ( - typeof baseUrl === "string" && XAI_NATIVE_ENDPOINT_HOSTS.has(resolveHostname(baseUrl) ?? "") - ); + return typeof baseUrl === "string" && resolveHostname(baseUrl) === "api.x.ai"; } export function supportsXaiPromptCacheKey(params: { api?: unknown; baseUrl?: unknown }): boolean { diff --git a/extensions/xai/speech-provider-metadata-factory.ts b/extensions/xai/speech-provider-metadata-factory.ts index 385c9d80efbb..408cdb9333ad 100644 --- a/extensions/xai/speech-provider-metadata-factory.ts +++ b/extensions/xai/speech-provider-metadata-factory.ts @@ -2,6 +2,7 @@ import type { PluginCapabilityCatalogContext } from "openclaw/plugin-sdk/plugin- import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { SpeechDirectiveTokenParseContext, + SpeechDirectiveTokenParseResult, SpeechProviderConfig, SpeechProviderOverrides, SpeechProviderPlugin, @@ -135,11 +136,9 @@ export function resolveDirectXaiAudioApiKey(configApiKey?: string): string | und return normalizeOptionalString(configApiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY); } -function parseXaiSpeechDirectiveToken(ctx: SpeechDirectiveTokenParseContext): { - handled: boolean; - overrides?: SpeechProviderOverrides; - warnings?: string[]; -} { +function parseXaiSpeechDirectiveToken( + ctx: SpeechDirectiveTokenParseContext, +): SpeechDirectiveTokenParseResult { switch (ctx.key) { case "voice": case "voice_id": diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts index f5d20c9c77b8..c6c7e362058b 100644 --- a/extensions/xai/src/responses-tool-shared.ts +++ b/extensions/xai/src/responses-tool-shared.ts @@ -19,18 +19,15 @@ function normalizeXaiCitationUrl(value: unknown): string | undefined { if (typeof value !== "string" || value.length > XAI_CITATION_URL_MAX_CHARS) { return undefined; } - try { - const url = new URL(value); - if ( - (url.protocol !== "http:" && url.protocol !== "https:") || - url.href.length > XAI_CITATION_URL_MAX_CHARS - ) { - return undefined; - } - return url.href === `${value}/` ? value : url.href; - } catch { + const url = URL.parse(value); + if ( + !url || + (url.protocol !== "http:" && url.protocol !== "https:") || + url.href.length > XAI_CITATION_URL_MAX_CHARS + ) { return undefined; } + return url.href === `${value}/` ? value : url.href; } function collectUrlCitations(annotations: unknown, citations: Set): void { diff --git a/extensions/xai/src/tool-config-shared.test.ts b/extensions/xai/src/tool-config-shared.test.ts index eec1957e7566..5c529df4cce8 100644 --- a/extensions/xai/src/tool-config-shared.test.ts +++ b/extensions/xai/src/tool-config-shared.test.ts @@ -1,17 +1,10 @@ -// Xai tests cover tool config shared plugin behavior. import { describe, expect, it } from "vitest"; import { - coerceXaiToolConfig, resolveNormalizedXaiToolModel, resolvePositiveIntegerToolConfig, } from "./tool-config-shared.js"; describe("xai tool config helpers", () => { - it("coerces non-record config to an empty object", () => { - expect(coerceXaiToolConfig(undefined)).toStrictEqual({}); - expect(coerceXaiToolConfig([] as unknown as Record)).toStrictEqual({}); - }); - it("normalizes configured model ids and falls back to the default model", () => { expect( resolveNormalizedXaiToolModel({ diff --git a/extensions/xai/src/tool-config-shared.ts b/extensions/xai/src/tool-config-shared.ts index f25b5f2ea552..2d20da7180df 100644 --- a/extensions/xai/src/tool-config-shared.ts +++ b/extensions/xai/src/tool-config-shared.ts @@ -1,17 +1,11 @@ -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeXaiModelId } from "../model-id.js"; -export function coerceXaiToolConfig( - config: Record | undefined, -): Record { - return isRecord(config) ? config : {}; -} - export function resolveNormalizedXaiToolModel(params: { config?: Record; defaultModel: string; }): string { - const value = coerceXaiToolConfig(params.config).model; + const value = asNonArrayRecord(params.config).model; return typeof value === "string" && value.trim() ? normalizeXaiModelId(value.trim()) : params.defaultModel; @@ -21,7 +15,7 @@ export function resolvePositiveIntegerToolConfig( config: Record | undefined, key: string, ): number | undefined { - const raw = coerceXaiToolConfig(config)[key]; + const raw = asNonArrayRecord(config)[key]; if (typeof raw !== "number" || !Number.isFinite(raw)) { return undefined; } diff --git a/extensions/xai/src/x-search-shared.ts b/extensions/xai/src/x-search-shared.ts index b280e5856624..e149dc413d47 100644 --- a/extensions/xai/src/x-search-shared.ts +++ b/extensions/xai/src/x-search-shared.ts @@ -1,3 +1,4 @@ +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_DEFAULT_MODEL_ID } from "../model-definitions.js"; import { requestXaiResponsesTool, @@ -6,7 +7,6 @@ import { resolveXaiResponsesEndpoint, } from "./responses-tool-shared.js"; import { - coerceXaiToolConfig, resolveNormalizedXaiToolModel, resolvePositiveIntegerToolConfig, } from "./tool-config-shared.js"; @@ -33,11 +33,11 @@ export function resolveXaiXSearchModel(config?: Record): string } export function resolveXaiXSearchEndpoint(config?: Record): string { - return resolveXaiResponsesEndpoint(coerceXaiToolConfig(config).baseUrl); + return resolveXaiResponsesEndpoint(asNonArrayRecord(config).baseUrl); } export function resolveXaiXSearchInlineCitations(config?: Record): boolean { - return coerceXaiToolConfig(config).inlineCitations === true; + return asNonArrayRecord(config).inlineCitations === true; } export function resolveXaiXSearchMaxTurns(config?: Record): number | undefined { diff --git a/extensions/xai/src/xai-user-agent.ts b/extensions/xai/src/xai-user-agent.ts index a2bd9e4b087b..85081348e385 100644 --- a/extensions/xai/src/xai-user-agent.ts +++ b/extensions/xai/src/xai-user-agent.ts @@ -24,13 +24,11 @@ export function xaiUserAgent(): string { return `${ORIGINATOR}/${resolveXaiUserAgentVersion()}`; } -const XAI_NATIVE_API_HOSTS = new Set(["api.x.ai"]); - // Returns a `User-Agent` header entry only when the resolved baseUrl points // at a verified xAI-native API host. User-configured proxy baseUrls produce // an empty record so the openclaw identity is not forwarded to the proxy. export function xaiUserAgentHeaderFor(baseUrl: string | undefined): Record { - if (baseUrl && XAI_NATIVE_API_HOSTS.has(URL.parse(baseUrl)?.hostname ?? "")) { + if (baseUrl && URL.parse(baseUrl)?.hostname === "api.x.ai") { return { "User-Agent": xaiUserAgent() }; } return {}; diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index eeead1ffb3c5..52938ad7fdd0 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -94,8 +94,7 @@ function toXaiTtsWsUrl(params: { responseFormat: XaiSpeechResponseFormat; speed?: number; }): string { - assertXaiNativeTtsStreamEndpoint(params.baseUrl); - const url = new URL(normalizeXaiTtsBaseUrl(params.baseUrl)); + const url = resolveXaiNativeTtsStreamEndpoint(params.baseUrl); url.protocol = "wss:"; const basePath = url.pathname.replace(/\/+$/, ""); url.pathname = `${basePath}/tts`; @@ -108,21 +107,11 @@ function toXaiTtsWsUrl(params: { return url.toString(); } -function readXaiTtsStreamErrorMessage(event: XaiTtsStreamServerEvent): string { - const message = trimToUndefined(event.message); - return message ?? "xAI TTS stream error"; -} - -function parseXaiTtsStreamBaseUrl(baseUrl: string): URL { - try { - return new URL(normalizeXaiTtsBaseUrl(baseUrl)); - } catch { +function resolveXaiNativeTtsStreamEndpoint(baseUrl: string): URL { + const url = URL.parse(normalizeXaiTtsBaseUrl(baseUrl)); + if (!url) { throw new Error(`Invalid xAI TTS stream baseUrl: ${baseUrl}`); } -} - -function assertXaiNativeTtsStreamEndpoint(baseUrl: string): void { - const url = parseXaiTtsStreamBaseUrl(baseUrl); if (url.protocol !== "https:") { throw new Error( `xAI streaming TTS only supports HTTPS for the native ${XAI_NATIVE_TTS_STREAM_HOST} endpoint; got protocol "${url.protocol}"`, @@ -138,6 +127,7 @@ function assertXaiNativeTtsStreamEndpoint(baseUrl: string): void { if (url.username || url.password || url.port || pathname !== "/v1" || url.search || url.hash) { throw new Error(`xAI streaming TTS requires the canonical ${XAI_BASE_URL} base URL`); } + return url; } export async function xaiTTSStream(params: XaiTtsRequest): Promise<{ @@ -353,7 +343,7 @@ export async function xaiTTSStream(params: XaiTtsRequest): Promise<{ void release(); return; case "error": - failStream(new Error(readXaiTtsStreamErrorMessage(event))); + failStream(new Error(trimToUndefined(event.message) ?? "xAI TTS stream error")); default: } }; diff --git a/packages/ai/src/api-registry.ts b/packages/ai/src/api-registry.ts index 84aef3a18a8f..9611bebb22e2 100644 --- a/packages/ai/src/api-registry.ts +++ b/packages/ai/src/api-registry.ts @@ -1,7 +1,5 @@ import type { Api, - AssistantMessageEventStreamContract, - Context, Model, SimpleStreamOptions, StreamFunction, @@ -9,18 +7,10 @@ import type { } from "@openclaw/llm-core"; /** Runtime stream adapter signature stored in the API provider registry. */ -export type ApiStreamFunction = ( - model: Model, - context: Context, - options?: StreamOptions, -) => AssistantMessageEventStreamContract; +export type ApiStreamFunction = StreamFunction; /** Runtime simple-stream adapter signature stored in the API provider registry. */ -export type ApiStreamSimpleFunction = ( - model: Model, - context: Context, - options?: SimpleStreamOptions, -) => AssistantMessageEventStreamContract; +export type ApiStreamSimpleFunction = StreamFunction; /** Provider implementation registered by core or plugins for a specific model API. */ export interface ApiProvider< diff --git a/packages/ai/src/providers/azure-openai-responses-client-compat.ts b/packages/ai/src/providers/azure-openai-responses-client-compat.ts index 3d57b869caf6..03ebc83c7506 100644 --- a/packages/ai/src/providers/azure-openai-responses-client-compat.ts +++ b/packages/ai/src/providers/azure-openai-responses-client-compat.ts @@ -5,14 +5,8 @@ export function isTraditionalAzureOpenAIHost(hostname: string): boolean { } export function isOpenAICompatibleAzureResponsesBaseUrl(baseUrl: string): boolean { - let url: URL; - try { - url = new URL(baseUrl); - } catch { - return false; - } - - if (isTraditionalAzureOpenAIHost(url.hostname)) { + const url = URL.parse(baseUrl); + if (!url || isTraditionalAzureOpenAIHost(url.hostname)) { return false; } diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index d0507e1be25e..78d5d2c57436 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -5,6 +5,7 @@ import { getAiTransportHost } from "../host.js"; import type { BaseOpenAIStreamOptions } from "../provider-options.js"; import type { OpenAIResponsesReplayMode } from "../transports/openai-responses-compaction-replay.js"; import type { OpenAIResponsesRequestParams } from "../transports/openai-responses-contracts.js"; +import { resolvePromptCacheKey } from "../transports/openai-transport-shared.js"; import type { Context, Model, SimpleStreamOptions, StreamFunction } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { requireApiKey } from "../utils/required-api-key.js"; @@ -13,7 +14,6 @@ import { isOpenAICompatibleAzureResponsesBaseUrl, isTraditionalAzureOpenAIHost, } from "./azure-openai-responses-client-compat.js"; -import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { resolveOpenAISimpleReasoningEffort, type OpenAIRequestReasoningEffort, @@ -106,10 +106,8 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction< function normalizeAzureBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim().replace(/\/+$/, ""); - let url: URL; - try { - url = new URL(trimmed); - } catch { + const url = URL.parse(trimmed); + if (!url) { throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`); } @@ -203,10 +201,7 @@ function buildParams( model: deploymentName, input: messages, stream: true, - prompt_cache_key: - options?.cacheRetention === "none" - ? undefined - : clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId), + prompt_cache_key: resolvePromptCacheKey(options, options?.cacheRetention ?? "short"), store: false, }; diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index c2e0a874acd0..e9bd7277ab60 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -107,12 +107,10 @@ function resolveCustomBaseUrl(baseUrl: string): string | undefined { } function baseUrlIncludesApiVersion(baseUrl: string): boolean { - try { - const url = new URL(baseUrl); - return url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part)); - } catch { - return /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl); - } + const url = URL.parse(baseUrl); + return url + ? url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part)) + : /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl); } function resolveApiKey(options?: GoogleVertexOptions): string | undefined { diff --git a/packages/ai/src/providers/openai-response-format.ts b/packages/ai/src/providers/openai-response-format.ts index f5c29904ebfc..7365191a0e1b 100644 --- a/packages/ai/src/providers/openai-response-format.ts +++ b/packages/ai/src/providers/openai-response-format.ts @@ -30,11 +30,7 @@ export function shouldOmitOllamaCompatResponseFormat(params: { if (params.hasTools()) { return true; } - try { - return new URL(params.baseUrl).origin === OLLAMA_CLOUD_ORIGIN; - } catch { - return false; - } + return URL.parse(params.baseUrl)?.origin === OLLAMA_CLOUD_ORIGIN; } /** diff --git a/packages/ai/src/transports/model-transport-url.ts b/packages/ai/src/transports/model-transport-url.ts index baf6c8013569..900b5ae64094 100644 --- a/packages/ai/src/transports/model-transport-url.ts +++ b/packages/ai/src/transports/model-transport-url.ts @@ -4,16 +4,15 @@ */ /** Return a sanitized URL suitable for logs and diagnostics. */ export function formatModelTransportDebugUrl(rawUrl: string): string { - try { - const parsed = new URL(rawUrl); - parsed.username = ""; - parsed.password = ""; - parsed.search = ""; - parsed.hash = ""; - return parsed.toString(); - } catch { + const parsed = URL.parse(rawUrl); + if (!parsed) { return ""; } + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); } /** Format a configured base URL for debug output, or the implicit default. */ diff --git a/packages/ai/src/transports/openai-completions-transport.ts b/packages/ai/src/transports/openai-completions-transport.ts index 645e1aa47d1a..6932a54af4c1 100644 --- a/packages/ai/src/transports/openai-completions-transport.ts +++ b/packages/ai/src/transports/openai-completions-transport.ts @@ -140,8 +140,9 @@ function buildOpenAICompletionsClientConfig( let baseURL = model.baseUrl; let isAzureHost = false; - try { - const parsed = new URL(model.baseUrl); + // Keep invalid configured URLs unchanged so the OpenAI SDK owns their errors. + const parsed = URL.parse(model.baseUrl); + if (parsed) { isAzureHost = isAzureOpenAICompatibleHost(parsed.hostname.toLowerCase()); parsed.searchParams.forEach((value, key) => { if (value) { @@ -150,8 +151,6 @@ function buildOpenAICompletionsClientConfig( }); parsed.search = ""; baseURL = parsed.toString().replace(/\/$/, ""); - } catch { - // Keep the configured base URL unchanged; the OpenAI SDK will surface invalid URLs. } if (isAzureHost) { diff --git a/packages/ai/src/transports/openai-transport-shared.ts b/packages/ai/src/transports/openai-transport-shared.ts index 73cffb9f55d2..5aa4ba23fe45 100644 --- a/packages/ai/src/transports/openai-transport-shared.ts +++ b/packages/ai/src/transports/openai-transport-shared.ts @@ -195,12 +195,8 @@ type MutableOpenAICompletionsReasoningBatch = { hasVisibleText: boolean; }; -const EMPTY_OPENAI_COMPLETIONS_REASONING_BATCH: OpenAICompletionsReasoningBatch = { - deltas: [], - mirroredThinking: [], - hasThinking: false, - hasVisibleText: false, -}; +const EMPTY_OPENAI_COMPLETIONS_REASONING_BATCH: OpenAICompletionsReasoningBatch = + createOpenAICompletionsReasoningBatch(); const OPENAI_COMPLETIONS_REASONING_FIELDS = [ "reasoning_content", diff --git a/packages/ai/src/transports/session-affinity.ts b/packages/ai/src/transports/session-affinity.ts index 40de0a29443d..fb1daa4acf28 100644 --- a/packages/ai/src/transports/session-affinity.ts +++ b/packages/ai/src/transports/session-affinity.ts @@ -1,12 +1,8 @@ import type { Model, StreamOptions } from "../types.js"; function isOpencodeEndpoint(baseUrl: string): boolean { - try { - const url = new URL(baseUrl); - return url.protocol === "https:" && url.hostname.replace(/\.$/, "") === "opencode.ai"; - } catch { - return false; - } + const url = URL.parse(baseUrl); + return url?.protocol === "https:" && url.hostname.replace(/\.$/, "") === "opencode.ai"; } /** Required conversation identity is independent of optional prompt caching. */ diff --git a/src/agents/auth-profiles/external-cli-auth-selection.ts b/src/agents/auth-profiles/external-cli-auth-selection.ts index 256b69c6b82e..15559db4bbbe 100644 --- a/src/agents/auth-profiles/external-cli-auth-selection.ts +++ b/src/agents/auth-profiles/external-cli-auth-selection.ts @@ -84,7 +84,6 @@ function resolveExternalCliAuthScopeFromAuthSelection(params: { ...discoveredProfileIds.filter((profileId) => profileId !== params.userPinnedAuthProfileId), ] : discoveredProfileIds; - let sawCompatibleOrderedProfile = false; let selectedProviderId: string | undefined; let compatibleProfileCount = 0; for (const profileId of profileIds) { @@ -96,9 +95,8 @@ function resolveExternalCliAuthScopeFromAuthSelection(params: { continue; } compatibleProfileCount += 1; - if (!sawCompatibleOrderedProfile) { + if (compatibleProfileCount === 1) { selectedProviderId = resolved.externalCliProviderId; - sawCompatibleOrderedProfile = true; } if (resolved.externalCliProviderId) { providerIds.push(resolved.externalCliProviderId); diff --git a/src/agents/auth-profiles/legacy-flat-credential.ts b/src/agents/auth-profiles/legacy-flat-credential.ts index 8802db55ed7b..7a00d7be2493 100644 --- a/src/agents/auth-profiles/legacy-flat-credential.ts +++ b/src/agents/auth-profiles/legacy-flat-credential.ts @@ -11,16 +11,13 @@ function inferLegacyCredentialType( if (explicit === "api_key" || explicit === "token" || explicit === "oauth") { return explicit; } - if (readNonEmptyString(record.key) ?? readNonEmptyString(record.apiKey)) { + if ( + (readNonEmptyString(record.key) ?? readNonEmptyString(record.apiKey)) || + coerceSecretRef(record.keyRef) + ) { return "api_key"; } - if (coerceSecretRef(record.keyRef)) { - return "api_key"; - } - if (readNonEmptyString(record.token)) { - return "token"; - } - if (coerceSecretRef(record.tokenRef)) { + if (readNonEmptyString(record.token) || coerceSecretRef(record.tokenRef)) { return "token"; } if ( diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 5a2ebfa9ee4f..29bf190041e1 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -435,6 +435,14 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { : resolveSharedAuthStorePath(); const globalRefreshLockPath = resolveOAuthRefreshLockPath(params.provider, params.profileId); const peerConfig = params.cfg ?? {}; + const fenceTerminalPeers = (credential: OAuthCredential) => + fenceOAuthRefreshPeers({ + cfg: peerConfig, + ownerDatabasePath: authPath, + profileId: params.profileId, + generation: credential, + fence: credential, + }); let observation: ReturnType | undefined; let observationTransferred = false; @@ -586,15 +594,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { if (isPendingOAuthRefreshFence(cred)) { return { kind: "observe", ownerAgentDir, generation: cred }; } - const peerClaims = personalProfile - ? [] - : await fenceOAuthRefreshPeers({ - cfg: peerConfig, - ownerDatabasePath: authPath, - profileId: params.profileId, - generation: cred, - fence: cred, - }); + const peerClaims = personalProfile ? [] : await fenceTerminalPeers(cred); failOAuthRefreshPeerClaims({ profileId: params.profileId, fence: cred, @@ -665,15 +665,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { }; } if (isOAuthRefreshFence(current)) { - const peerClaims = personalProfile - ? [] - : await fenceOAuthRefreshPeers({ - cfg: peerConfig, - ownerDatabasePath: authPath, - profileId: params.profileId, - generation: current, - fence: current, - }); + const peerClaims = personalProfile ? [] : await fenceTerminalPeers(current); failOAuthRefreshPeerClaims({ profileId: params.profileId, fence: current, diff --git a/src/agents/auth-profiles/portability.ts b/src/agents/auth-profiles/portability.ts index b3add8ff7502..50d23979ee1f 100644 --- a/src/agents/auth-profiles/portability.ts +++ b/src/agents/auth-profiles/portability.ts @@ -20,21 +20,6 @@ export type AuthProfilePortability = { reason: AuthProfilePortabilityReason; }; -// OAuth refresh material is not copied by default because it can be tied to a -// local profile/keychain flow. Static credentials are portable unless opted out. -function hasAgentCopyOverride(credential: AuthProfileCredential): boolean | undefined { - return typeof credential.copyToAgents === "boolean" ? credential.copyToAgents : undefined; -} - -function hasCopyableOAuthMaterial(credential: AuthProfileCredential): boolean { - if (credential.type !== "oauth") { - return false; - } - return [credential.access, credential.refresh].some( - (value) => typeof value === "string" && value.trim().length > 0, - ); -} - /** Resolves whether a credential can be copied into an agent-local store. */ export function resolveAuthProfilePortability( credential: AuthProfileCredential, @@ -42,12 +27,16 @@ export function resolveAuthProfilePortability( if (credential.setup?.replacement) { return { portable: false, reason: "setup-inactive" }; } - const override = hasAgentCopyOverride(credential); + const override = credential.copyToAgents; if (override === false) { return { portable: false, reason: "credential-opted-out" }; } if (credential.type === "oauth") { - if (!hasCopyableOAuthMaterial(credential)) { + if ( + ![credential.access, credential.refresh].some( + (value) => typeof value === "string" && value.trim().length > 0, + ) + ) { return { portable: false, reason: "non-portable-oauth-refresh-token" }; } return override === true diff --git a/src/agents/auth-profiles/profile-list.ts b/src/agents/auth-profiles/profile-list.ts index 4b7a4d323761..39d9e449c0b4 100644 --- a/src/agents/auth-profiles/profile-list.ts +++ b/src/agents/auth-profiles/profile-list.ts @@ -8,9 +8,7 @@ import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import type { AuthProfileStore } from "./types.js"; /** Deduplicates profile ids while preserving first-seen order. */ -export function dedupeProfileIds(profileIds: string[]): string[] { - return uniqueStrings(profileIds); -} +export const dedupeProfileIds: (profileIds: string[]) => string[] = uniqueStrings; /** Lists auth profile ids whose credential provider matches the requested provider. */ export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] { diff --git a/src/agents/auth-profiles/profile-usage-stats.ts b/src/agents/auth-profiles/profile-usage-stats.ts index c3bfa3298bdb..3e970012bd66 100644 --- a/src/agents/auth-profiles/profile-usage-stats.ts +++ b/src/agents/auth-profiles/profile-usage-stats.ts @@ -5,25 +5,10 @@ import type { AuthProfileBlockedReason, AuthProfileBlockedSource, AuthProfileCooldownClassification, - AuthProfileFailureReason, ProfileUsageStats, } from "./types.js"; +import { AUTH_PROFILE_FAILURE_REASONS } from "./usage-state.js"; -const AUTH_FAILURE_REASONS = new Set([ - "auth", - "auth_permanent", - "format", - "overloaded", - "rate_limit", - "billing", - "timeout", - "model_not_found", - "session_expired", - "empty_response", - "no_error_details", - "unclassified", - "unknown", -]); const AUTH_COOLDOWN_CLASSIFICATIONS = new Set([ "wham_token_expired", "wham_account_dead", @@ -49,7 +34,7 @@ function normalizeFailureCounts(raw: unknown): ProfileUsageStats["failureCounts" } const normalized: NonNullable = {}; for (const [rawReason, count] of Object.entries(raw)) { - const reason = normalizeEnumValue(rawReason, AUTH_FAILURE_REASONS); + const reason = normalizeEnumValue(rawReason, AUTH_PROFILE_FAILURE_REASONS); if (reason === undefined) { continue; } @@ -66,7 +51,7 @@ export function coerceProfileUsageStats(raw: unknown): ProfileUsageStats | undef if (!isRecord(raw)) { return undefined; } - const cooldownReason = normalizeEnumValue(raw.cooldownReason, AUTH_FAILURE_REASONS); + const cooldownReason = normalizeEnumValue(raw.cooldownReason, AUTH_PROFILE_FAILURE_REASONS); const cooldownClassification = normalizeEnumValue( raw.cooldownClassification, AUTH_COOLDOWN_CLASSIFICATIONS, @@ -94,7 +79,7 @@ export function coerceProfileUsageStats(raw: unknown): ProfileUsageStats | undef ); setStat("cooldownModel", normalizeOptionalString(raw.cooldownModel)); setStat("disabledUntil", asFiniteNumber(raw.disabledUntil)); - setStat("disabledReason", normalizeEnumValue(raw.disabledReason, AUTH_FAILURE_REASONS)); + setStat("disabledReason", normalizeEnumValue(raw.disabledReason, AUTH_PROFILE_FAILURE_REASONS)); setStat("errorCount", asFiniteNumber(raw.errorCount)); setStat("failureCounts", normalizeFailureCounts(raw.failureCounts)); setStat("lastFailureAt", asFiniteNumber(raw.lastFailureAt)); diff --git a/src/agents/auth-profiles/repair.ts b/src/agents/auth-profiles/repair.ts index 5e090149d749..8c7a18bbbc18 100644 --- a/src/agents/auth-profiles/repair.ts +++ b/src/agents/auth-profiles/repair.ts @@ -24,11 +24,7 @@ function getProfileSuffix(profileId: string): string { } function isEmailLike(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - return trimmed.includes("@") && trimmed.includes("."); + return value.includes("@") && value.includes("."); } /** Suggests a modern OAuth profile id for a legacy provider:default profile. */ diff --git a/src/agents/auth-profiles/usage-state.ts b/src/agents/auth-profiles/usage-state.ts index 829a5548fc7b..e78105cf9644 100644 --- a/src/agents/auth-profiles/usage-state.ts +++ b/src/agents/auth-profiles/usage-state.ts @@ -1,5 +1,6 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; +import { isStringOption } from "../../utils/string-readers.js"; import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js"; const FAILURE_REASON_PRIORITY: AuthProfileFailureReason[] = [ @@ -17,11 +18,9 @@ const FAILURE_REASON_PRIORITY: AuthProfileFailureReason[] = [ "unclassified", "unknown", ]; -const FAILURE_REASON_SET = new Set(FAILURE_REASON_PRIORITY); - -function isAuthProfileFailureReason(reason: string): reason is AuthProfileFailureReason { - return FAILURE_REASON_SET.has(reason); -} +export const AUTH_PROFILE_FAILURE_REASONS: ReadonlySet = new Set( + FAILURE_REASON_PRIORITY, +); /** Clears failure windows while preserving unrelated usage history. */ export function resetAuthProfileFailureState( @@ -308,7 +307,7 @@ export function resolveProfilesUnavailableReason(params: { const now = params.now ?? Date.now(); const scores = new Map(); const addScore = (reason: AuthProfileFailureReason, value: number) => { - if (!FAILURE_REASON_SET.has(reason) || value <= 0 || !Number.isFinite(value)) { + if (!AUTH_PROFILE_FAILURE_REASONS.has(reason) || value <= 0 || !Number.isFinite(value)) { return; } scores.set(reason, (scores.get(reason) ?? 0) + value); @@ -321,7 +320,11 @@ export function resolveProfilesUnavailableReason(params: { } const disabledActive = isActiveUnusableWindow(stats.disabledUntil, now); - if (disabledActive && stats.disabledReason && FAILURE_REASON_SET.has(stats.disabledReason)) { + if ( + disabledActive && + stats.disabledReason && + AUTH_PROFILE_FAILURE_REASONS.has(stats.disabledReason) + ) { // Disabled reasons are explicit and high-signal; weight heavily. addScore(stats.disabledReason, 1_000); continue; @@ -337,7 +340,7 @@ export function resolveProfilesUnavailableReason(params: { continue; } - if (stats.cooldownReason && FAILURE_REASON_SET.has(stats.cooldownReason)) { + if (stats.cooldownReason && AUTH_PROFILE_FAILURE_REASONS.has(stats.cooldownReason)) { addScore(stats.cooldownReason, 1_000); continue; } @@ -345,7 +348,7 @@ export function resolveProfilesUnavailableReason(params: { let recordedReason = false; for (const [reason, rawCount] of Object.entries(stats.failureCounts ?? {})) { const count = typeof rawCount === "number" ? rawCount : 0; - if (!isAuthProfileFailureReason(reason) || count <= 0) { + if (!isStringOption(reason, AUTH_PROFILE_FAILURE_REASONS) || count <= 0) { continue; } addScore(reason, count); diff --git a/src/agents/model-catalog-route.ts b/src/agents/model-catalog-route.ts index f413e7b6e17a..7c9592441e92 100644 --- a/src/agents/model-catalog-route.ts +++ b/src/agents/model-catalog-route.ts @@ -246,18 +246,17 @@ export function projectModelCatalogEntryForRoute(params: { /** Returns true for loopback, wildcard, and mDNS local base URLs. */ export const isLocalBaseUrl = (baseUrl: string) => { - try { - const url = new URL(baseUrl); - const host = normalizeLowercaseStringOrEmpty(url.hostname).replace(/^\[|\]$/g, ""); - return ( - host === "localhost" || - (isCanonicalDottedDecimalIPv4(host) && isLoopbackIpAddress(host)) || - host === "0.0.0.0" || - host === "::" || - host === "::1" || - host.endsWith(".local") - ); - } catch { + const url = URL.parse(baseUrl); + if (!url) { return false; } + const host = normalizeLowercaseStringOrEmpty(url.hostname).replace(/^\[|\]$/g, ""); + return ( + host === "localhost" || + (isCanonicalDottedDecimalIPv4(host) && isLoopbackIpAddress(host)) || + host === "0.0.0.0" || + host === "::" || + host === "::1" || + host.endsWith(".local") + ); }; diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index be5c091c3324..0ec66f3d1c95 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -14,7 +14,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import pMap from "p-map"; import { Type } from "typebox"; import { formatErrorMessage } from "../infra/errors.js"; @@ -208,11 +208,7 @@ async function fetchOpenRouterModels( asPositiveSafeInteger(obj.max_output_tokens) ?? null; - const supportedParameters = Array.isArray(obj.supported_parameters) - ? normalizeStringEntries( - obj.supported_parameters.filter((value) => typeof value === "string"), - ) - : []; + const supportedParameters = normalizeTrimmedStringList(obj.supported_parameters); return { id, diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index 1291c5277885..4059f961e502 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -6,6 +6,7 @@ import type { ModelCatalogContextWindowOption } from "@openclaw/model-catalog-co */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { mergeModelCost } from "../config/model-cost.js"; import type { @@ -117,14 +118,8 @@ export function mergeProviderModels( ): ProviderModelCatalog { const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; - const implicitHeaders = - implicit.headers && typeof implicit.headers === "object" && !Array.isArray(implicit.headers) - ? implicit.headers - : undefined; - const explicitHeaders = - explicit.headers && typeof explicit.headers === "object" && !Array.isArray(explicit.headers) - ? explicit.headers - : undefined; + const implicitHeaders = isRecord(implicit.headers) ? implicit.headers : undefined; + const explicitHeaders = isRecord(explicit.headers) ? explicit.headers : undefined; const mergeProviderFields = () => ({ ...implicit, ...explicit, diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index 6b0a78633222..dd2565050a45 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -75,7 +75,7 @@ export type PreparedModelRuntimeBuildResult = Readonly<{ pluginGeneration: PreparedModelRuntimePluginGeneration; }>; -function groupBuildCandidates( +export function groupBuildCandidates( candidates: readonly T[], keyOf: (candidate: T) => K, ): Map { diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index 49a0f0c8dbf5..7fa73b94c335 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -13,6 +13,7 @@ import { resolveLegacyInheritedAuthDir } from "./legacy-inherited-auth-dir.js"; import { preparePublishedModelCatalogOwnerIdentity } from "./prepared-model-catalog-owner.js"; import { copyPreparedModelRuntimeAuthBindings } from "./prepared-model-runtime-auth.js"; import { + groupBuildCandidates, startSerializedSnapshotBuildBatch, type PreparedModelRuntimeBuildResult, } from "./prepared-model-runtime.build.js"; @@ -494,15 +495,7 @@ export async function publishPreparedModelRuntimeOwnerBatch( owner, }; }); - const groups = new Map(); - for (const candidate of candidates) { - const group = groups.get(candidate.catalogMode); - if (group) { - group.push(candidate); - } else { - groups.set(candidate.catalogMode, [candidate]); - } - } + const groups = groupBuildCandidates(candidates, (candidate) => candidate.catalogMode); const results = new Map(); const publishCandidate = (candidate: (typeof candidates)[number]) => { if (!candidate.isCurrent()) { diff --git a/src/agents/provider-local-service-diagnostics.ts b/src/agents/provider-local-service-diagnostics.ts index 466aaf7aff5b..0dac08d35c01 100644 --- a/src/agents/provider-local-service-diagnostics.ts +++ b/src/agents/provider-local-service-diagnostics.ts @@ -1,4 +1,5 @@ import { isSensitiveFieldKey, redactSensitiveText } from "../logging/redact.js"; +import { truncateUtf8Suffix } from "../utils/utf8-truncate.js"; const LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES = 8 * 1024; @@ -42,19 +43,7 @@ export function appendLocalServiceOutputTail( redacted = redacted.replaceAll(value, "[redacted]"); } } - const bytes = Buffer.from(redacted); - if (bytes.byteLength <= LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES) { - return redacted; - } - let start = bytes.byteLength - LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES; - while (start < bytes.byteLength) { - const byte = bytes.at(start); - if (byte === undefined || (byte & 0xc0) !== 0x80) { - break; - } - start += 1; - } - return bytes.subarray(start).toString("utf8"); + return truncateUtf8Suffix(redacted, LOCAL_SERVICE_OUTPUT_TAIL_MAX_BYTES); } export function formatLocalServiceDiagnosticTail(diagnostics: LocalServiceDiagnostics): string { diff --git a/src/agents/provider-model-route.ts b/src/agents/provider-model-route.ts index f2051897b8c6..2f1f38ccc04d 100644 --- a/src/agents/provider-model-route.ts +++ b/src/agents/provider-model-route.ts @@ -9,6 +9,7 @@ import { resolveProviderModelRoutes, } from "../plugins/provider-model-routes.js"; import type { ModelCatalogRoutePolicy } from "./model-catalog-route.js"; +import { normalizeCatalogRouteBaseUrl } from "./model-compat-catalog.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; /** Canonicalizes a model id only when its provider owns catalog equivalence. */ @@ -34,16 +35,6 @@ export function isProviderModelRerouted( ); } -function normalizeRouteBaseUrl(value: string): string { - try { - const url = new URL(value); - url.pathname = url.pathname.replace(/\/+$/u, "") || "/"; - return url.toString(); - } catch { - return value.replace(/\/+$/u, ""); - } -} - function routeTupleMatches( source: { api?: string | null; baseUrl?: string }, route: ProviderModelRouteCandidate, @@ -51,7 +42,8 @@ function routeTupleMatches( return ( source.api === route.api && typeof source.baseUrl === "string" && - normalizeRouteBaseUrl(source.baseUrl) === normalizeRouteBaseUrl(route.baseUrl) + (normalizeCatalogRouteBaseUrl(source.baseUrl) ?? "") === + (normalizeCatalogRouteBaseUrl(route.baseUrl) ?? "") ); }