From c6e6380c5ed86bbeb7799b0a91c982e9bc50ac42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 11:03:40 -0700 Subject: [PATCH] refactor(providers): deslop provider glue (#162897) Reuse existing requirements, WebSocket byte conversion, normalization, auth failure, diagnostic truncation, stream type, and catalog grouping owners across provider adapters and agent glue. Remove private forwarding layers while preserving provider policy and lifecycle boundaries. Reject nonfinite Vercel catalog prices and per-million conversion overflow using the existing fallback. Regression cases cover numeric and scaled overflow. Production reduction: 427 lines. Full provider and core suites, changed checks, plugin contracts, import boundaries, SDK API comparison, and zero-cycle checks validate the refactor; fresh isolated review found no actionable issue. --- extensions/amazon-bedrock/bedrock-options.ts | 26 ++------- .../amazon-bedrock/register.sync.runtime.ts | 14 ++--- extensions/amazon-bedrock/stream.runtime.ts | 17 ++---- extensions/cloudflare-ai-gateway/index.ts | 11 ++-- extensions/codex/index.ts | 4 +- .../codex/src/app-server/attempt-context.ts | 10 ++-- .../app-server/attempt-workspace-context.ts | 9 +-- .../app-server/canonical-fork.test-support.ts | 4 +- .../codex/src/app-server/config-options.ts | 7 +-- .../src/app-server/config-requirements.ts | 52 ++++++----------- .../src/app-server/config-reviewer-policy.ts | 8 +-- .../codex/src/app-server/config-security.ts | 22 ++----- .../app-server/context-engine-projection.ts | 15 ++--- .../dynamic-tool-construction-plan.ts | 10 +--- .../app-server/event-projector-assistant.ts | 23 +++----- .../src/app-server/inference-dispatch.ts | 9 --- .../codex/src/app-server/inference-proxy.ts | 8 +-- .../src/app-server/run-attempt-runtime.ts | 15 ++--- .../sandbox-exec-server-node-relay.ts | 25 +++----- .../src/app-server/sandbox-exec-server.ts | 24 ++++---- .../app-server/session-permission-policy.ts | 41 ++++++------- .../codex/src/app-server/side-question.ts | 12 +--- .../src/app-server/transport-websocket.ts | 15 +---- .../codex/src/app-server/turn-router.ts | 13 ++--- .../codex/src/app-server/websocket-data.ts | 11 ++++ extensions/codex/src/command-account.ts | 8 +-- .../codex/src/command-diagnostics-support.ts | 9 +-- .../codex/src/command-plugin-app-links.ts | 25 ++++---- extensions/codex/src/migration/source.ts | 12 +--- .../src/session-catalog-list-operation.ts | 7 --- .../session-catalog-originator-peer.test.ts | 19 ++++--- extensions/codex/src/session-catalog-types.ts | 4 -- .../codex/src/session-catalog.test-helpers.ts | 28 +++++---- extensions/codex/src/session-catalog.ts | 12 +--- extensions/codex/src/supervision-tools.ts | 17 +++--- extensions/deepinfra/index.ts | 3 - extensions/github-copilot/embeddings.ts | 17 +++--- extensions/github-copilot/runtime-auth.ts | 6 +- extensions/github-copilot/starter-model.ts | 9 +-- extensions/google/realtime-voice-provider.ts | 12 ++-- extensions/google/speech-models.ts | 3 +- extensions/google/speech-provider.ts | 9 ++- extensions/google/speech-synthesis.ts | 18 +----- extensions/google/src/google-api-base-url.ts | 9 +-- .../google/video-generation-provider.ts | 21 ++----- extensions/lmstudio/src/models.fetch.ts | 7 +-- extensions/lmstudio/src/models.ts | 13 ++--- extensions/lmstudio/src/provider-auth.ts | 13 ++--- extensions/lmstudio/src/setup-prompts.ts | 12 ++-- .../minimax/image-generation-provider.ts | 8 +-- extensions/minimax/provider-catalog.ts | 18 ++---- extensions/nvidia/provider-catalog.ts | 16 ++---- .../openai/image-generation-provider.ts | 57 +++++++------------ .../openrouter/video-generation-provider.ts | 8 +-- extensions/qwen/models.ts | 14 ++--- extensions/stepfun/index.ts | 16 ++---- extensions/vercel-ai-gateway/models.ts | 12 +--- .../provider-catalog.test.ts | 38 +++++++++++++ extensions/xai/provider-routing.ts | 6 +- .../xai/speech-provider-metadata-factory.ts | 9 ++- extensions/xai/src/responses-tool-shared.ts | 17 +++--- extensions/xai/src/tool-config-shared.test.ts | 7 --- extensions/xai/src/tool-config-shared.ts | 12 +--- extensions/xai/src/x-search-shared.ts | 6 +- extensions/xai/src/xai-user-agent.ts | 4 +- extensions/xai/tts.ts | 22 ++----- packages/ai/src/api-registry.ts | 14 +---- .../azure-openai-responses-client-compat.ts | 10 +--- .../src/providers/azure-openai-responses.ts | 13 ++--- packages/ai/src/providers/google-vertex.ts | 10 ++-- .../src/providers/openai-response-format.ts | 6 +- .../ai/src/transports/model-transport-url.ts | 15 +++-- .../openai-completions-transport.ts | 7 +-- .../src/transports/openai-transport-shared.ts | 8 +-- .../ai/src/transports/session-affinity.ts | 8 +-- .../external-cli-auth-selection.ts | 4 +- .../auth-profiles/legacy-flat-credential.ts | 13 ++--- src/agents/auth-profiles/oauth-manager.ts | 28 ++++----- src/agents/auth-profiles/portability.ts | 23 ++------ src/agents/auth-profiles/profile-list.ts | 4 +- .../auth-profiles/profile-usage-stats.ts | 23 ++------ src/agents/auth-profiles/repair.ts | 6 +- src/agents/auth-profiles/usage-state.ts | 21 ++++--- src/agents/model-catalog-route.ts | 23 ++++---- src/agents/model-scan.ts | 8 +-- src/agents/models-config.merge.ts | 11 +--- src/agents/prepared-model-runtime.build.ts | 2 +- src/agents/prepared-model-runtime.owner.ts | 11 +--- .../provider-local-service-diagnostics.ts | 15 +---- src/agents/provider-model-route.ts | 14 +---- 90 files changed, 441 insertions(+), 824 deletions(-) create mode 100644 extensions/codex/src/app-server/websocket-data.ts 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) ?? "") ); }