Files
openclaw/src/agents/models-config.merge.ts
2026-04-08 00:09:42 +01:00

261 lines
7.9 KiB
TypeScript

import { normalizeOptionalString } from "../shared/string-coerce.js";
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
import type { ProviderConfig } from "./models-config.providers.secrets.js";
export type ExistingProviderConfig = ProviderConfig & {
apiKey?: string;
baseUrl?: string;
api?: string;
};
function isPositiveFiniteTokenLimit(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function resolvePreferredTokenLimit(params: {
explicitPresent: boolean;
explicitValue: unknown;
implicitValue: unknown;
}): number | undefined {
if (params.explicitPresent && isPositiveFiniteTokenLimit(params.explicitValue)) {
return params.explicitValue;
}
if (isPositiveFiniteTokenLimit(params.implicitValue)) {
return params.implicitValue;
}
return isPositiveFiniteTokenLimit(params.explicitValue) ? params.explicitValue : undefined;
}
function getProviderModelId(model: unknown): string {
if (!model || typeof model !== "object") {
return "";
}
const id = (model as { id?: unknown }).id;
return normalizeOptionalString(id) ?? "";
}
export function mergeProviderModels(
implicit: ProviderConfig,
explicit: ProviderConfig,
): ProviderConfig {
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;
if (implicitModels.length === 0) {
return {
...implicit,
...explicit,
...(implicitHeaders || explicitHeaders
? {
headers: {
...implicitHeaders,
...explicitHeaders,
},
}
: {}),
};
}
const implicitById = new Map(
implicitModels
.map((model) => [getProviderModelId(model), model] as const)
.filter(([id]) => Boolean(id)),
);
const seen = new Set<string>();
const mergedModels = explicitModels.map((explicitModel) => {
const id = getProviderModelId(explicitModel);
if (!id) {
return explicitModel;
}
seen.add(id);
const implicitModel = implicitById.get(id);
if (!implicitModel) {
return explicitModel;
}
const contextWindow = resolvePreferredTokenLimit({
explicitPresent: "contextWindow" in explicitModel,
explicitValue: explicitModel.contextWindow,
implicitValue: implicitModel.contextWindow,
});
const contextTokens = resolvePreferredTokenLimit({
explicitPresent: "contextTokens" in explicitModel,
explicitValue: explicitModel.contextTokens,
implicitValue: implicitModel.contextTokens,
});
const maxTokens = resolvePreferredTokenLimit({
explicitPresent: "maxTokens" in explicitModel,
explicitValue: explicitModel.maxTokens,
implicitValue: implicitModel.maxTokens,
});
return {
...explicitModel,
input: implicitModel.input,
reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning,
...(contextWindow === undefined ? {} : { contextWindow }),
...(contextTokens === undefined ? {} : { contextTokens }),
...(maxTokens === undefined ? {} : { maxTokens }),
};
});
for (const implicitModel of implicitModels) {
const id = getProviderModelId(implicitModel);
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
mergedModels.push(implicitModel);
}
return {
...implicit,
...explicit,
...(implicitHeaders || explicitHeaders
? {
headers: {
...implicitHeaders,
...explicitHeaders,
},
}
: {}),
models: mergedModels,
};
}
export function mergeProviders(params: {
implicit?: Record<string, ProviderConfig> | null;
explicit?: Record<string, ProviderConfig> | null;
}): Record<string, ProviderConfig> {
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
const providerKey = normalizeOptionalString(key) ?? "";
if (!providerKey) {
continue;
}
const implicit = out[providerKey];
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
}
return out;
}
function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined {
return normalizeOptionalString(entry?.api);
}
function resolveModelApiSurface(entry: { models?: unknown } | undefined): string | undefined {
if (!Array.isArray(entry?.models)) {
return undefined;
}
const apis = entry.models
.flatMap((model) => {
if (!model || typeof model !== "object") {
return [];
}
const api = (model as { api?: unknown }).api;
const normalized = normalizeOptionalString(api);
return normalized ? [normalized] : [];
})
.toSorted();
return apis.length > 0 ? JSON.stringify(apis) : undefined;
}
function resolveProviderApiSurface(
entry: ExistingProviderConfig | ProviderConfig | undefined,
): string | undefined {
return resolveProviderApi(entry) ?? resolveModelApiSurface(entry);
}
function shouldPreserveExistingApiKey(params: {
providerKey: string;
existing: ExistingProviderConfig;
nextEntry: ProviderConfig;
secretRefManagedProviders: ReadonlySet<string>;
}): boolean {
const { providerKey, existing, nextEntry, secretRefManagedProviders } = params;
const nextApiKey = typeof nextEntry.apiKey === "string" ? nextEntry.apiKey : "";
if (nextApiKey && isNonSecretApiKeyMarker(nextApiKey)) {
return false;
}
return (
!secretRefManagedProviders.has(providerKey) &&
typeof existing.apiKey === "string" &&
existing.apiKey.length > 0 &&
!isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })
);
}
function shouldPreserveExistingBaseUrl(params: {
providerKey: string;
existing: ExistingProviderConfig;
nextEntry: ProviderConfig;
explicitBaseUrlProviders: ReadonlySet<string>;
}): boolean {
const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params;
if (
explicitBaseUrlProviders.has(providerKey) ||
typeof existing.baseUrl !== "string" ||
existing.baseUrl.length === 0
) {
return false;
}
const existingApi = resolveProviderApiSurface(existing);
const nextApi = resolveProviderApiSurface(nextEntry);
return !existingApi || !nextApi || existingApi === nextApi;
}
export function mergeWithExistingProviderSecrets(params: {
nextProviders: Record<string, ProviderConfig>;
existingProviders: Record<string, ExistingProviderConfig>;
secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
}): Record<string, ProviderConfig> {
const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } =
params;
const mergedProviders: Record<string, ProviderConfig> = {};
for (const [key, entry] of Object.entries(existingProviders)) {
mergedProviders[key] = entry;
}
for (const [key, newEntry] of Object.entries(nextProviders)) {
const existing = existingProviders[key];
if (!existing) {
mergedProviders[key] = newEntry;
continue;
}
const preserved: Record<string, unknown> = {};
if (
shouldPreserveExistingApiKey({
providerKey: key,
existing,
nextEntry: newEntry,
secretRefManagedProviders,
})
) {
preserved.apiKey = existing.apiKey;
}
if (
shouldPreserveExistingBaseUrl({
providerKey: key,
existing,
nextEntry: newEntry,
explicitBaseUrlProviders,
})
) {
preserved.baseUrl = existing.baseUrl;
}
mergedProviders[key] = { ...newEntry, ...preserved };
}
return mergedProviders;
}