Files
openclaw/src/agents/provider-request-config.ts
2026-04-11 02:15:21 +01:00

745 lines
22 KiB
TypeScript

import type { Api } from "@mariozechner/pi-ai";
import type { ModelDefinitionConfig } from "../config/types.js";
import type {
ConfiguredModelProviderRequest,
ConfiguredProviderRequest,
} from "../config/types.provider-request.js";
import { assertSecretInputResolved } from "../config/types.secrets.js";
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import type {
ProviderRequestCapabilities,
ProviderRequestCapability,
ProviderRequestTransport,
} from "./provider-attribution.js";
import {
resolveProviderRequestCapabilities,
resolveProviderRequestPolicy,
type ProviderRequestPolicyResolution,
} from "./provider-attribution.js";
type RequestApi = Api | ModelDefinitionConfig["api"];
export type ProviderRequestAuthOverride =
| {
mode: "provider-default";
}
| {
mode: "authorization-bearer";
token: string;
}
| {
mode: "header";
headerName: string;
value: string;
prefix?: string;
};
export type ProviderRequestTlsOverride = {
ca?: string;
cert?: string;
key?: string;
passphrase?: string;
serverName?: string;
insecureSkipVerify?: boolean;
};
export type ProviderRequestProxyOverride =
| {
mode: "env-proxy";
tls?: ProviderRequestTlsOverride;
}
| {
mode: "explicit-proxy";
url: string;
tls?: ProviderRequestTlsOverride;
};
export type ProviderRequestTransportOverrides = {
headers?: Record<string, string>;
auth?: ProviderRequestAuthOverride;
proxy?: ProviderRequestProxyOverride;
tls?: ProviderRequestTlsOverride;
};
export type ModelProviderRequestTransportOverrides = ProviderRequestTransportOverrides & {
allowPrivateNetwork?: boolean;
};
export type ResolvedProviderRequestAuthConfig =
| {
configured: false;
mode: "provider-default" | "authorization-bearer";
injectAuthorizationHeader: boolean;
}
| {
configured: true;
mode: "authorization-bearer";
headerName: "Authorization";
value: string;
injectAuthorizationHeader: true;
}
| {
configured: true;
mode: "header";
headerName: string;
value: string;
prefix?: string;
injectAuthorizationHeader: false;
};
export type ResolvedProviderRequestProxyConfig =
| {
configured: false;
}
| {
configured: true;
mode: "env-proxy";
tls: ResolvedProviderRequestTlsConfig;
}
| {
configured: true;
mode: "explicit-proxy";
proxyUrl: string;
tls: ResolvedProviderRequestTlsConfig;
};
export type ResolvedProviderRequestTlsConfig =
| {
configured: false;
}
| {
configured: true;
ca?: string;
cert?: string;
key?: string;
passphrase?: string;
serverName?: string;
rejectUnauthorized?: boolean;
};
export type ResolvedProviderRequestExtraHeadersConfig = {
configured: boolean;
headers?: Record<string, string>;
};
export type ResolvedProviderRequestConfig = {
api?: RequestApi;
baseUrl?: string;
headers?: Record<string, string>;
extraHeaders: ResolvedProviderRequestExtraHeadersConfig;
auth: ResolvedProviderRequestAuthConfig;
proxy: ResolvedProviderRequestProxyConfig;
tls: ResolvedProviderRequestTlsConfig;
policy: ProviderRequestPolicyResolution;
};
export type ProviderRequestHeaderPrecedence = "caller-wins" | "defaults-win";
export type ResolvedProviderRequestPolicyConfig = ResolvedProviderRequestConfig & {
allowPrivateNetwork: boolean;
capabilities: ProviderRequestCapabilities;
};
const FORBIDDEN_HEADER_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const FORBIDDEN_INSECURE_TLS_MESSAGE =
"Provider transport overrides do not allow insecureSkipVerify";
const FORBIDDEN_RUNTIME_TRANSPORT_OVERRIDE_MESSAGE =
"Runtime auth request overrides do not allow proxy or TLS transport settings";
type ResolveProviderRequestPolicyConfigParams = {
provider?: string;
api?: RequestApi;
baseUrl?: string;
defaultBaseUrl?: string;
capability?: ProviderRequestCapability;
transport?: ProviderRequestTransport;
discoveredHeaders?: Record<string, string>;
providerHeaders?: Record<string, string>;
modelHeaders?: Record<string, string>;
callerHeaders?: Record<string, string>;
precedence?: ProviderRequestHeaderPrecedence;
authHeader?: boolean;
compat?: {
supportsStore?: boolean;
} | null;
modelId?: string | null;
allowPrivateNetwork?: boolean;
request?: ModelProviderRequestTransportOverrides;
};
function sanitizeConfiguredRequestString(value: unknown, path: string): string | undefined {
if (typeof value !== "string") {
// Config transport overrides are sanitized after secrets runtime resolution.
// Fail closed if a raw SecretRef leaks into this path instead of silently dropping it.
assertSecretInputResolved({ value, path });
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function sanitizeConfiguredProviderRequest(
request: ConfiguredProviderRequest | undefined,
): ProviderRequestTransportOverrides | undefined {
if (!request || typeof request !== "object" || Array.isArray(request)) {
return undefined;
}
let headers: Record<string, string> | undefined;
if (request.headers && typeof request.headers === "object" && !Array.isArray(request.headers)) {
const nextHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(request.headers)) {
const sanitized = sanitizeConfiguredRequestString(value, `request.headers.${key}`);
if (sanitized) {
nextHeaders[key] = sanitized;
}
}
if (Object.keys(nextHeaders).length > 0) {
headers = nextHeaders;
}
}
let auth: ProviderRequestAuthOverride | undefined;
const rawAuth = request.auth;
if (rawAuth && typeof rawAuth === "object" && !Array.isArray(rawAuth)) {
if (rawAuth.mode === "provider-default") {
auth = { mode: "provider-default" };
} else if (rawAuth.mode === "authorization-bearer") {
const token = sanitizeConfiguredRequestString(rawAuth.token, "request.auth.token");
if (token) {
auth = { mode: "authorization-bearer", token };
}
} else if (rawAuth.mode === "header") {
const headerName = sanitizeConfiguredRequestString(
rawAuth.headerName,
"request.auth.headerName",
);
const value = sanitizeConfiguredRequestString(rawAuth.value, "request.auth.value");
const prefix = sanitizeConfiguredRequestString(rawAuth.prefix, "request.auth.prefix");
if (headerName && value) {
auth = {
mode: "header",
headerName,
value,
...(prefix ? { prefix } : {}),
};
}
}
}
const sanitizeTls = (
tls: unknown,
pathPrefix: "request.tls" | "request.proxy.tls",
): ProviderRequestTlsOverride | undefined => {
if (!tls || typeof tls !== "object" || Array.isArray(tls)) {
return undefined;
}
const rawTls = tls as Record<string, unknown>;
const next: ProviderRequestTlsOverride = {};
const ca = sanitizeConfiguredRequestString(rawTls.ca, `${pathPrefix}.ca`);
const cert = sanitizeConfiguredRequestString(rawTls.cert, `${pathPrefix}.cert`);
const key = sanitizeConfiguredRequestString(rawTls.key, `${pathPrefix}.key`);
const passphrase = sanitizeConfiguredRequestString(
rawTls.passphrase,
`${pathPrefix}.passphrase`,
);
const serverName = sanitizeConfiguredRequestString(
rawTls.serverName,
`${pathPrefix}.serverName`,
);
if (ca) {
next.ca = ca;
}
if (cert) {
next.cert = cert;
}
if (key) {
next.key = key;
}
if (passphrase) {
next.passphrase = passphrase;
}
if (serverName) {
next.serverName = serverName;
}
if (rawTls.insecureSkipVerify === true) {
next.insecureSkipVerify = true;
} else if (rawTls.insecureSkipVerify === false) {
next.insecureSkipVerify = false;
}
return Object.keys(next).length > 0 ? next : undefined;
};
let proxy: ProviderRequestProxyOverride | undefined;
const rawProxy = request.proxy;
if (rawProxy && typeof rawProxy === "object" && !Array.isArray(rawProxy)) {
const tls = sanitizeTls(rawProxy.tls, "request.proxy.tls");
if (rawProxy.mode === "env-proxy") {
proxy = {
mode: "env-proxy",
...(tls ? { tls } : {}),
};
} else if (rawProxy.mode === "explicit-proxy") {
const url = sanitizeConfiguredRequestString(rawProxy.url, "request.proxy.url");
if (url) {
proxy = {
mode: "explicit-proxy",
url,
...(tls ? { tls } : {}),
};
}
}
}
const tls = sanitizeTls(request.tls, "request.tls");
if (!headers && !auth && !proxy && !tls) {
return undefined;
}
return {
...(headers ? { headers } : {}),
...(auth ? { auth } : {}),
...(proxy ? { proxy } : {}),
...(tls ? { tls } : {}),
};
}
export function sanitizeConfiguredModelProviderRequest(
request: ConfiguredModelProviderRequest | undefined,
): ModelProviderRequestTransportOverrides | undefined {
const sanitized = sanitizeConfiguredProviderRequest(request);
const rawAllow = request?.allowPrivateNetwork;
const allowPrivateNetwork = rawAllow === true ? true : rawAllow === false ? false : undefined;
if (!sanitized && allowPrivateNetwork === undefined) {
return undefined;
}
return {
...sanitized,
...(allowPrivateNetwork !== undefined ? { allowPrivateNetwork } : {}),
};
}
export function mergeProviderRequestOverrides(
...overrides: Array<ProviderRequestTransportOverrides | undefined>
): ProviderRequestTransportOverrides | undefined {
const merged: ProviderRequestTransportOverrides = {};
let hasMerged = false;
for (const current of overrides) {
if (!current) {
continue;
}
hasMerged = true;
if (current.headers) {
merged.headers = Object.assign({}, merged.headers, current.headers);
}
if (current.auth) {
merged.auth = current.auth;
}
if (current.proxy) {
merged.proxy = current.proxy;
}
if (current.tls) {
merged.tls = current.tls;
}
}
return hasMerged ? merged : undefined;
}
export function mergeModelProviderRequestOverrides(
...overrides: Array<ModelProviderRequestTransportOverrides | undefined>
): ModelProviderRequestTransportOverrides | undefined {
let merged: ModelProviderRequestTransportOverrides | undefined = mergeProviderRequestOverrides(
...overrides,
);
for (const current of overrides) {
if (current?.allowPrivateNetwork !== undefined) {
merged ??= {};
merged.allowPrivateNetwork = current.allowPrivateNetwork;
}
}
return merged;
}
export function normalizeBaseUrl(baseUrl: string | undefined, fallback: string): string;
export function normalizeBaseUrl(
baseUrl: string | undefined,
fallback?: string,
): string | undefined;
export function normalizeBaseUrl(
baseUrl: string | undefined,
fallback?: string,
): string | undefined {
const raw = baseUrl?.trim() || fallback?.trim();
if (!raw) {
return undefined;
}
return raw.replace(/\/+$/, "");
}
export function mergeProviderRequestHeaders(
...headerSets: Array<Record<string, string> | undefined>
): Record<string, string> | undefined {
let merged: Record<string, string> | undefined;
const headerNamesByLowerKey = new Map<string, string>();
for (const headers of headerSets) {
if (!headers) {
continue;
}
if (!merged) {
merged = Object.create(null) as Record<string, string>;
}
for (const [key, value] of Object.entries(headers)) {
const normalizedKey = normalizeLowercaseStringOrEmpty(key);
if (FORBIDDEN_HEADER_KEYS.has(normalizedKey)) {
continue;
}
const previousKey = headerNamesByLowerKey.get(normalizedKey);
if (previousKey && previousKey !== key) {
delete merged[previousKey];
}
merged[key] = value;
headerNamesByLowerKey.set(normalizedKey, key);
}
}
return merged && Object.keys(merged).length > 0 ? merged : undefined;
}
function resolveTlsOverride(
tls: ProviderRequestTlsOverride | undefined,
): ResolvedProviderRequestTlsConfig {
if (!tls) {
return { configured: false };
}
if (tls.insecureSkipVerify === true) {
throw new Error(FORBIDDEN_INSECURE_TLS_MESSAGE);
}
const ca = tls.ca?.trim();
const cert = tls.cert?.trim();
const key = tls.key?.trim();
const passphrase = tls.passphrase?.trim();
const serverName = tls.serverName?.trim();
const rejectUnauthorized = tls.insecureSkipVerify === false ? true : undefined;
if (!ca && !cert && !key && !passphrase && !serverName && rejectUnauthorized === undefined) {
return { configured: false };
}
return {
configured: true,
...(ca ? { ca } : {}),
...(cert ? { cert } : {}),
...(key ? { key } : {}),
...(passphrase ? { passphrase } : {}),
...(serverName ? { serverName } : {}),
...(rejectUnauthorized !== undefined ? { rejectUnauthorized } : {}),
};
}
function resolveAuthOverride(params: {
authHeader?: boolean;
request?: ProviderRequestTransportOverrides;
}): ResolvedProviderRequestAuthConfig {
const auth = params.request?.auth;
if (auth?.mode === "authorization-bearer") {
const value = auth.token.trim();
if (value) {
return {
configured: true,
mode: "authorization-bearer",
headerName: "Authorization",
value,
injectAuthorizationHeader: true,
};
}
}
if (auth?.mode === "header") {
const headerName = auth.headerName.trim();
const value = auth.value.trim();
const prefix = auth.prefix?.trim();
if (headerName && value) {
return {
configured: true,
mode: "header",
headerName,
value,
...(prefix ? { prefix } : {}),
injectAuthorizationHeader: false,
};
}
}
return {
configured: false,
mode: params.authHeader ? "authorization-bearer" : "provider-default",
injectAuthorizationHeader: params.authHeader === true,
};
}
export function sanitizeRuntimeProviderRequestOverrides(
request: ProviderRequestTransportOverrides | undefined,
): ProviderRequestTransportOverrides | undefined {
if (!request) {
return undefined;
}
if (request.proxy || request.tls) {
throw new Error(FORBIDDEN_RUNTIME_TRANSPORT_OVERRIDE_MESSAGE);
}
const headers = request.headers;
const auth = request.auth;
if (!headers && !auth) {
return undefined;
}
return {
...(headers ? { headers } : {}),
...(auth ? { auth } : {}),
};
}
function resolveProxyOverride(
request: ProviderRequestTransportOverrides | undefined,
): ResolvedProviderRequestProxyConfig {
const proxy = request?.proxy;
if (!proxy) {
return { configured: false };
}
const tls = resolveTlsOverride(proxy.tls);
if (proxy.mode === "env-proxy") {
return {
configured: true,
mode: "env-proxy",
tls,
};
}
const proxyUrl = proxy.url.trim();
if (!proxyUrl) {
return { configured: false };
}
return {
configured: true,
mode: "explicit-proxy",
proxyUrl,
tls,
};
}
function applyResolvedAuthHeader(
headers: Record<string, string> | undefined,
auth: ResolvedProviderRequestAuthConfig,
): Record<string, string> | undefined {
if (!auth.configured) {
return headers;
}
const next = mergeProviderRequestHeaders(headers) ?? Object.create(null);
const keysToDelete = new Set([normalizeLowercaseStringOrEmpty(auth.headerName)]);
if (auth.mode === "header") {
keysToDelete.add("authorization");
}
for (const key of Object.keys(next)) {
if (keysToDelete.has(normalizeLowercaseStringOrEmpty(key))) {
delete next[key];
}
}
next[auth.headerName] =
auth.mode === "authorization-bearer"
? `Bearer ${auth.value}`
: `${auth.prefix ?? ""}${auth.value}`;
return Object.keys(next).length > 0 ? next : undefined;
}
function toTlsConnectOptions(
tls: ResolvedProviderRequestTlsConfig,
): Record<string, unknown> | undefined {
if (!tls.configured) {
return undefined;
}
const next: Record<string, unknown> = {};
if (tls.ca) {
next.ca = tls.ca;
}
if (tls.cert) {
next.cert = tls.cert;
}
if (tls.key) {
next.key = tls.key;
}
if (tls.passphrase) {
next.passphrase = tls.passphrase;
}
if (tls.serverName) {
next.servername = tls.serverName;
}
if (tls.rejectUnauthorized !== undefined) {
next.rejectUnauthorized = tls.rejectUnauthorized;
}
return Object.keys(next).length > 0 ? next : undefined;
}
export function buildProviderRequestDispatcherPolicy(
request: Pick<ResolvedProviderRequestConfig, "proxy" | "tls">,
): PinnedDispatcherPolicy | undefined {
const targetTls = toTlsConnectOptions(request.tls);
if (!request.proxy.configured) {
return targetTls ? { mode: "direct", connect: targetTls } : undefined;
}
const proxiedTls = toTlsConnectOptions(request.proxy.tls);
if (request.proxy.mode === "env-proxy") {
return {
mode: "env-proxy",
...(targetTls ? { connect: { ...targetTls } } : {}),
...(proxiedTls ? { proxyTls: { ...proxiedTls } } : {}),
};
}
return {
mode: "explicit-proxy",
proxyUrl: request.proxy.proxyUrl,
...(proxiedTls ? { proxyTls: proxiedTls } : {}),
};
}
export function buildProviderRequestTlsClientOptions(
request: Pick<ResolvedProviderRequestConfig, "tls">,
): Record<string, unknown> | undefined {
return toTlsConnectOptions(request.tls);
}
export function resolveProviderRequestPolicyConfig(
params: ResolveProviderRequestPolicyConfigParams,
): ResolvedProviderRequestPolicyConfig {
const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl);
const capability = params.capability ?? "llm";
const transport = params.transport ?? "http";
const policyInput = {
provider: params.provider,
api: params.api,
baseUrl,
capability,
transport,
} satisfies Parameters<typeof resolveProviderRequestPolicy>[0];
const policy = resolveProviderRequestPolicy(policyInput);
const capabilities = resolveProviderRequestCapabilities({
...policyInput,
compat: params.compat,
modelId: params.modelId,
});
const auth = resolveAuthOverride({
authHeader: params.authHeader,
request: params.request,
});
const extraHeaders = applyResolvedAuthHeader(
mergeProviderRequestHeaders(
params.discoveredHeaders,
params.providerHeaders,
params.modelHeaders,
params.request?.headers,
),
auth,
);
const protectedAttributionKeys = new Set(
Object.keys(policy.attributionHeaders ?? {}).map((key) => normalizeLowercaseStringOrEmpty(key)),
);
const unprotectedCallerHeaders = params.callerHeaders
? Object.fromEntries(
Object.entries(params.callerHeaders).filter(
([key]) => !protectedAttributionKeys.has(normalizeLowercaseStringOrEmpty(key)),
),
)
: undefined;
const mergedDefaults = mergeProviderRequestHeaders(extraHeaders, policy.attributionHeaders);
const headers =
params.precedence === "caller-wins"
? mergeProviderRequestHeaders(mergedDefaults, unprotectedCallerHeaders)
: mergeProviderRequestHeaders(unprotectedCallerHeaders, mergedDefaults);
return {
api: params.api,
baseUrl,
headers,
extraHeaders: {
configured: Boolean(extraHeaders),
headers: extraHeaders,
},
auth,
proxy: resolveProxyOverride(params.request),
tls: resolveTlsOverride(params.request?.tls),
policy,
capabilities,
allowPrivateNetwork: params.allowPrivateNetwork ?? false,
};
}
export function resolveProviderRequestConfig(params: {
provider: string;
api?: RequestApi;
baseUrl?: string;
capability?: ProviderRequestCapability;
transport?: ProviderRequestTransport;
discoveredHeaders?: Record<string, string>;
providerHeaders?: Record<string, string>;
modelHeaders?: Record<string, string>;
authHeader?: boolean;
request?: ProviderRequestTransportOverrides;
}): ResolvedProviderRequestConfig {
const resolved = resolveProviderRequestPolicyConfig(params);
return {
api: resolved.api,
baseUrl: resolved.baseUrl,
// Model resolution intentionally excludes attribution headers. Those are
// applied later at transport/request time so native-host gating stays tied
// to the final resolved route instead of the catalog/config merge step.
headers: resolved.extraHeaders.headers,
extraHeaders: resolved.extraHeaders,
auth: resolved.auth,
proxy: resolved.proxy,
tls: resolved.tls,
policy: resolved.policy,
};
}
export function resolveProviderRequestHeaders(params: {
provider: string;
api?: RequestApi;
baseUrl?: string;
capability?: ProviderRequestCapability;
transport?: ProviderRequestTransport;
callerHeaders?: Record<string, string>;
defaultHeaders?: Record<string, string>;
precedence?: ProviderRequestHeaderPrecedence;
request?: ProviderRequestTransportOverrides;
}): Record<string, string> | undefined {
return resolveProviderRequestPolicyConfig({
provider: params.provider,
api: params.api,
baseUrl: params.baseUrl,
capability: params.capability,
transport: params.transport,
callerHeaders: params.callerHeaders,
providerHeaders: params.defaultHeaders,
precedence: params.precedence,
request: params.request,
}).headers;
}
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
"openclaw.modelProviderRequestTransport",
);
type ModelWithProviderRequestTransport = {
[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL]?: ModelProviderRequestTransportOverrides;
};
export function attachModelProviderRequestTransport<TModel extends object>(
model: TModel,
request: ModelProviderRequestTransportOverrides | undefined,
): TModel {
if (!request) {
return model;
}
const next = { ...model } as TModel & ModelWithProviderRequestTransport;
next[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL] = request;
return next;
}
export function getModelProviderRequestTransport(
model: object,
): ModelProviderRequestTransportOverrides | undefined {
return (model as ModelWithProviderRequestTransport)[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL];
}