mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
1bc8ad24e8
commit
c6e6380c5e
90 changed files with 441 additions and 824 deletions
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
11
extensions/codex/src/app-server/websocket-data.ts
Normal file
11
extensions/codex/src/app-server/websocket-data.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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)) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -137,10 +137,6 @@ export type CodexSessionCatalogHost = {
|
|||
error?: CodexSessionCatalogError;
|
||||
};
|
||||
|
||||
export type CodexSessionCatalogResult = {
|
||||
hosts: CodexSessionCatalogHost[];
|
||||
};
|
||||
|
||||
export type CodexSessionTranscriptPage = {
|
||||
hostId: string;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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" }] });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue