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.
This commit is contained in:
Peter Steinberger 2026-10-01 11:03:40 -07:00 • committed by GitHub
parent 1bc8ad24e8
commit c6e6380c5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 441 additions and 824 deletions

View file

@ -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),
);
}

View file

@ -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);
}
}
}

View file

@ -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(

View file

@ -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<ProviderAuthContext, "prompter"> & { 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 };
}

View file

@ -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,

View file

@ -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();

View file

@ -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<CodexWorkspaceBootstrapContext> {
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;
}

View file

@ -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,

View file

@ -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");

View file

@ -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<CodexSandboxMode> | 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<CodexAppServerManagedApprovalPolicy> | undefined {
return parseRequirementsValues(
parseCodexRequirements(content)?.allowed_approval_policies,
normalizeRequirementsApprovalPolicy,
);
}
export function parseAllowedApprovalsReviewersFromCodexRequirements(
content: string,
): Set<CodexApprovalsReviewer> | 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(

View file

@ -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 {

View file

@ -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 =

View file

@ -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({

View file

@ -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,

View file

@ -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;

View file

@ -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"));

View file

@ -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) {

View file

@ -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,

View file

@ -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<voi
}
function normalizeCodexExecServerFrame(data: RawData | Uint8Array): Buffer {
const frame = Array.isArray(data)
? Buffer.concat(data)
: Buffer.isBuffer(data)
? data
: data instanceof Uint8Array
? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
: Buffer.from(data);
const frame = codexWebSocketDataToBuffer(data);
if (frame.length > 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;
}

View file

@ -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<void> {
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")));
}

View file

@ -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<CodexSandboxMode> | undefined;
approvalPolicies: Set<CodexAppServerManagedApprovalPolicy> | undefined;
reviewers: Set<CodexApprovalsReviewer> | undefined;
},
allowed: ReturnType<typeof parseCodexRequirementsPolicy>,
): 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"),
};
}

View file

@ -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,

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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(

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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 });

View file

@ -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<THost>(
operation.close();
}
}
export async function listCodexSessionCatalog(
params: ListParams,
): Promise<CodexSessionCatalogResult> {
return { hosts: await runCatalogListInline(createCodexSessionCatalogListOperation(params)) };
}

View file

@ -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<typeof listCodexSessionCatalog>[0];
const result = await listCodexSessionCatalog(listParams);
} satisfies Parameters<typeof createCodexSessionCatalogListOperation>[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)),

View file

@ -137,10 +137,6 @@ export type CodexSessionCatalogHost = {
error?: CodexSessionCatalogError;
};
export type CodexSessionCatalogResult = {
hosts: CodexSessionCatalogHost[];
};
export type CodexSessionTranscriptPage = {
hostId: string;
label: string;

View file

@ -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<typeof createCodexSessionCatalogControlRuntime>[0],
@ -189,15 +190,22 @@ function asControlFactory(
};
}
export function listCodexSessionCatalog(
params: Omit<Parameters<typeof listCodexSessionCatalogRuntime>[0], "control"> & {
export async function listCodexSessionCatalog(
params: Omit<Parameters<typeof createCodexSessionCatalogListOperation>[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<

View file

@ -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<typeof import("./session-catalog-adoption.js").continueLocalCodexSession>
) {

View file

@ -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(

View file

@ -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)

View file

@ -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;
}

View file

@ -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();

View file

@ -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.",

View file

@ -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<void>((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;
}

View file

@ -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 {

View file

@ -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":

View file

@ -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 {

View file

@ -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());

View file

@ -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;

View file

@ -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");
}

View file

@ -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. */

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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(

View file

@ -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 {

View file

@ -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;
}

View file

@ -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 }

View file

@ -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: {

View file

@ -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 {

View file

@ -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;
}

View file

@ -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"] {

View file

@ -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" }] });

View file

@ -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 {

View file

@ -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":

View file

@ -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<string>): void {

View file

@ -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<string, unknown>)).toStrictEqual({});
});
it("normalizes configured model ids and falls back to the default model", () => {
expect(
resolveNormalizedXaiToolModel({

View file

@ -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<string, unknown> | undefined,
): Record<string, unknown> {
return isRecord(config) ? config : {};
}
export function resolveNormalizedXaiToolModel(params: {
config?: Record<string, unknown>;
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<string, unknown> | undefined,
key: string,
): number | undefined {
const raw = coerceXaiToolConfig(config)[key];
const raw = asNonArrayRecord(config)[key];
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}

View file

@ -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, unknown>): string
}
export function resolveXaiXSearchEndpoint(config?: Record<string, unknown>): string {
return resolveXaiResponsesEndpoint(coerceXaiToolConfig(config).baseUrl);
return resolveXaiResponsesEndpoint(asNonArrayRecord(config).baseUrl);
}
export function resolveXaiXSearchInlineCitations(config?: Record<string, unknown>): boolean {
return coerceXaiToolConfig(config).inlineCitations === true;
return asNonArrayRecord(config).inlineCitations === true;
}
export function resolveXaiXSearchMaxTurns(config?: Record<string, unknown>): number | undefined {

View file

@ -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<string, string> {
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 {};

View file

@ -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:
}
};

View file

@ -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<Api, SimpleStreamOptions>;
/** Provider implementation registered by core or plugins for a specific model API. */
export interface ApiProvider<

View file

@ -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;
}

View file

@ -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,
};

View file

@ -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 {

View file

@ -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;
}
/**

View file

@ -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 "<invalid-url>";
}
parsed.username = "";
parsed.password = "";
parsed.search = "";
parsed.hash = "";
return parsed.toString();
}
/** Format a configured base URL for debug output, or the implicit default. */

View file

@ -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) {

View file

@ -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",

View file

@ -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. */

View file

@ -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);

View file

@ -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 (

View file

@ -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<typeof beginOAuthRefreshObservation> | 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,

View file

@ -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

View file

@ -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[] {

View file

@ -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<AuthProfileFailureReason>([
"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<AuthProfileCooldownClassification>([
"wham_token_expired",
"wham_account_dead",
@ -49,7 +34,7 @@ function normalizeFailureCounts(raw: unknown): ProfileUsageStats["failureCounts"
}
const normalized: NonNullable<ProfileUsageStats["failureCounts"]> = {};
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));

View file

@ -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. */

View file

@ -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<string>(FAILURE_REASON_PRIORITY);
function isAuthProfileFailureReason(reason: string): reason is AuthProfileFailureReason {
return FAILURE_REASON_SET.has(reason);
}
export const AUTH_PROFILE_FAILURE_REASONS: ReadonlySet<AuthProfileFailureReason> = 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<AuthProfileFailureReason, number>();
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);

View file

@ -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")
);
};

View file

@ -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,

View file

@ -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,

View file

@ -75,7 +75,7 @@ export type PreparedModelRuntimeBuildResult = Readonly<{
pluginGeneration: PreparedModelRuntimePluginGeneration;
}>;
function groupBuildCandidates<T extends PreparedModelRuntimeBuildCandidate, K>(
export function groupBuildCandidates<T extends PreparedModelRuntimeBuildCandidate, K>(
candidates: readonly T[],
keyOf: (candidate: T) => K,
): Map<K, T[]> {

View file

@ -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<PreparedModelRuntimeOwner["catalogMode"], typeof candidates>();
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<PreparedModelRuntimeOwner, PreparedModelRuntimeBuildResult>();
const publishCandidate = (candidate: (typeof candidates)[number]) => {
if (!candidate.isCurrent()) {

View file

@ -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 {

View file

@ -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) ?? "")
);
}