diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts new file mode 100644 index 00000000000..72075dffc00 --- /dev/null +++ b/src/plugins/provider-auth-helpers.ts @@ -0,0 +1,262 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; +import type { SecretInputMode } from "../commands/onboard-types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + coerceSecretRef, + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; + +const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; + +const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); + +export type ApiKeyStorageOptions = { + secretInputMode?: SecretInputMode; +}; + +export type WriteOAuthCredentialsOptions = { + syncSiblingAgents?: boolean; +}; + +function buildEnvSecretRef(id: string): SecretRef { + return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; +} + +function parseEnvSecretRef(value: string): SecretRef | null { + const match = ENV_REF_PATTERN.exec(value); + if (!match) { + return null; + } + return buildEnvSecretRef(match[1]); +} + +function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { + const envVars = PROVIDER_ENV_VARS[provider]; + const envVar = envVars?.find((candidate) => candidate.trim().length > 0); + if (!envVar) { + throw new Error( + `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, + ); + } + return buildEnvSecretRef(envVar); +} + +function resolveApiKeySecretInput( + provider: string, + input: SecretInput, + options?: ApiKeyStorageOptions, +): SecretInput { + const coercedRef = coerceSecretRef(input); + if (coercedRef) { + return coercedRef; + } + const normalized = normalizeSecretInput(input); + const inlineEnvRef = parseEnvSecretRef(normalized); + if (inlineEnvRef) { + return inlineEnvRef; + } + if (options?.secretInputMode === "ref") { + return resolveProviderDefaultEnvSecretRef(provider); + } + return normalized; +} + +export function buildApiKeyCredential( + provider: string, + input: SecretInput, + metadata?: Record, + options?: ApiKeyStorageOptions, +): { + type: "api_key"; + provider: string; + key?: string; + keyRef?: SecretRef; + metadata?: Record; +} { + const secretInput = resolveApiKeySecretInput(provider, input, options); + if (typeof secretInput === "string") { + return { + type: "api_key", + provider, + key: secretInput, + ...(metadata ? { metadata } : {}), + }; + } + return { + type: "api_key", + provider, + keyRef: secretInput, + ...(metadata ? { metadata } : {}), + }; +} + +export function applyAuthProfileConfig( + cfg: OpenClawConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "oauth" | "token"; + email?: string; + preferProfileFirst?: boolean; + }, +): OpenClawConfig { + const normalizedProvider = normalizeProviderIdForAuth(params.provider); + const profiles = { + ...cfg.auth?.profiles, + [params.profileId]: { + provider: params.provider, + mode: params.mode, + ...(params.email ? { email: params.email } : {}), + }, + }; + + const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) + .filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === normalizedProvider) + .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); + + // Maintain `auth.order` when it already exists. Additionally, if we detect + // mixed auth modes for the same provider, keep the newly selected profile first. + const existingProviderOrder = cfg.auth?.order?.[params.provider]; + const preferProfileFirst = params.preferProfileFirst ?? true; + const reorderedProviderOrder = + existingProviderOrder && preferProfileFirst + ? [ + params.profileId, + ...existingProviderOrder.filter((profileId) => profileId !== params.profileId), + ] + : existingProviderOrder; + const hasMixedConfiguredModes = configuredProviderProfiles.some( + ({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode, + ); + const derivedProviderOrder = + existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes + ? [ + params.profileId, + ...configuredProviderProfiles + .map(({ profileId }) => profileId) + .filter((profileId) => profileId !== params.profileId), + ] + : undefined; + const order = + existingProviderOrder !== undefined + ? { + ...cfg.auth?.order, + [params.provider]: reorderedProviderOrder?.includes(params.profileId) + ? reorderedProviderOrder + : [...(reorderedProviderOrder ?? []), params.profileId], + } + : derivedProviderOrder + ? { + ...cfg.auth?.order, + [params.provider]: derivedProviderOrder, + } + : cfg.auth?.order; + return { + ...cfg, + auth: { + ...cfg.auth, + profiles, + ...(order ? { order } : {}), + }, + }; +} + +/** Resolve real path, returning null if the target doesn't exist. */ +function safeRealpathSync(dir: string): string | null { + try { + return fs.realpathSync(path.resolve(dir)); + } catch { + return null; + } +} + +function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { + const normalized = path.resolve(primaryAgentDir); + const parentOfAgent = path.dirname(normalized); + const candidateAgentsRoot = path.dirname(parentOfAgent); + const looksLikeStandardLayout = + path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; + + const agentsRoot = looksLikeStandardLayout + ? candidateAgentsRoot + : path.join(resolveStateDir(), "agents"); + + const entries = (() => { + try { + return fs.readdirSync(agentsRoot, { withFileTypes: true }); + } catch { + return []; + } + })(); + const discovered = entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(agentsRoot, entry.name, "agent")); + + const seen = new Set(); + const result: string[] = []; + for (const dir of [normalized, ...discovered]) { + const real = safeRealpathSync(dir); + if (real && !seen.has(real)) { + seen.add(real); + result.push(real); + } + } + return result; +} + +export async function writeOAuthCredentials( + provider: string, + creds: OAuthCredentials, + agentDir?: string, + options?: WriteOAuthCredentialsOptions, +): Promise { + const email = + typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; + const profileId = `${provider}:${email}`; + const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); + const targetAgentDirs = options?.syncSiblingAgents + ? resolveSiblingAgentDirs(resolvedAgentDir) + : [resolvedAgentDir]; + + const credential = { + type: "oauth" as const, + provider, + ...creds, + }; + + upsertAuthProfile({ + profileId, + credential, + agentDir: resolvedAgentDir, + }); + + if (options?.syncSiblingAgents) { + const primaryReal = safeRealpathSync(resolvedAgentDir); + for (const targetAgentDir of targetAgentDirs) { + const targetReal = safeRealpathSync(targetAgentDir); + if (targetReal && primaryReal && targetReal === primaryReal) { + continue; + } + try { + upsertAuthProfile({ + profileId, + credential, + agentDir: targetAgentDir, + }); + } catch { + // Best-effort: sibling sync failure must not block primary setup. + } + } + } + return profileId; +} diff --git a/src/plugins/provider-auth-storage.ts b/src/plugins/provider-auth-storage.ts new file mode 100644 index 00000000000..d8e15115902 --- /dev/null +++ b/src/plugins/provider-auth-storage.ts @@ -0,0 +1,345 @@ +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js"; +import { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +} from "./provider-auth-helpers.js"; + +const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); + +export { KILOCODE_DEFAULT_MODEL_REF }; +export { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +}; + +export async function setAnthropicApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "anthropic:default", + credential: buildApiKeyCredential("anthropic", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setOpenaiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "openai:default", + credential: buildApiKeyCredential("openai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setGeminiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "google:default", + credential: buildApiKeyCredential("google", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setMinimaxApiKey( + key: SecretInput, + agentDir?: string, + profileId: string = "minimax:default", + options?: ApiKeyStorageOptions, +) { + const provider = profileId.split(":")[0] ?? "minimax"; + upsertAuthProfile({ + profileId, + credential: buildApiKeyCredential(provider, key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setMoonshotApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "moonshot:default", + credential: buildApiKeyCredential("moonshot", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setKimiCodingApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "kimi:default", + credential: buildApiKeyCredential("kimi", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setVolcengineApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "volcengine:default", + credential: buildApiKeyCredential("volcengine", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setByteplusApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "byteplus:default", + credential: buildApiKeyCredential("byteplus", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setSyntheticApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "synthetic:default", + credential: buildApiKeyCredential("synthetic", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setVeniceApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "venice:default", + credential: buildApiKeyCredential("venice", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export const ZAI_DEFAULT_MODEL_REF = "zai/glm-5"; +export const XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash"; +export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; +export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; +export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; +export const LITELLM_DEFAULT_MODEL_REF = "litellm/claude-opus-4-6"; +export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; + +export async function setZaiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "zai:default", + credential: buildApiKeyCredential("zai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setXiaomiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "xiaomi:default", + credential: buildApiKeyCredential("xiaomi", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setOpenrouterApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + const safeKey = typeof key === "string" && key === "undefined" ? "" : key; + upsertAuthProfile({ + profileId: "openrouter:default", + credential: buildApiKeyCredential("openrouter", safeKey, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setCloudflareAiGatewayConfig( + accountId: string, + gatewayId: string, + apiKey: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + const normalizedAccountId = accountId.trim(); + const normalizedGatewayId = gatewayId.trim(); + upsertAuthProfile({ + profileId: "cloudflare-ai-gateway:default", + credential: buildApiKeyCredential( + "cloudflare-ai-gateway", + apiKey, + { + accountId: normalizedAccountId, + gatewayId: normalizedGatewayId, + }, + options, + ), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setLitellmApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "litellm:default", + credential: buildApiKeyCredential("litellm", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setVercelAiGatewayApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "vercel-ai-gateway:default", + credential: buildApiKeyCredential("vercel-ai-gateway", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setOpencodeZenApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + await setSharedOpencodeApiKey(key, agentDir, options); +} + +export async function setOpencodeGoApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + await setSharedOpencodeApiKey(key, agentDir, options); +} + +async function setSharedOpencodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + for (const provider of ["opencode", "opencode-go"] as const) { + upsertAuthProfile({ + profileId: `${provider}:default`, + credential: buildApiKeyCredential(provider, key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); + } +} + +export async function setTogetherApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "together:default", + credential: buildApiKeyCredential("together", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setHuggingfaceApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "huggingface:default", + credential: buildApiKeyCredential("huggingface", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setQianfanApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "qianfan:default", + credential: buildApiKeyCredential("qianfan", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setModelStudioApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "modelstudio:default", + credential: buildApiKeyCredential("modelstudio", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setXaiApiKey(key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions) { + upsertAuthProfile({ + profileId: "xai:default", + credential: buildApiKeyCredential("xai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setMistralApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "mistral:default", + credential: buildApiKeyCredential("mistral", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setKilocodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "kilocode:default", + credential: buildApiKeyCredential("kilocode", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} diff --git a/src/plugins/provider-onboarding-config.ts b/src/plugins/provider-onboarding-config.ts new file mode 100644 index 00000000000..9e70eaac192 --- /dev/null +++ b/src/plugins/provider-onboarding-config.ts @@ -0,0 +1,221 @@ +import { findNormalizedProviderKey } from "../agents/provider-id.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; +import type { + ModelApi, + ModelDefinitionConfig, + ModelProviderConfig, +} from "../config/types.models.js"; + +function extractAgentDefaultModelFallbacks(model: unknown): string[] | undefined { + if (!model || typeof model !== "object") { + return undefined; + } + if (!("fallbacks" in model)) { + return undefined; + } + const fallbacks = (model as { fallbacks?: unknown }).fallbacks; + return Array.isArray(fallbacks) ? fallbacks.map((v) => String(v)) : undefined; +} + +export function applyOnboardAuthAgentModelsAndProviders( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providers: Record; + }, +): OpenClawConfig { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models: params.agentModels, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers: params.providers, + }, + }; +} + +export function applyAgentDefaultModelPrimary( + cfg: OpenClawConfig, + primary: string, +): OpenClawConfig { + const existingFallbacks = extractAgentDefaultModelFallbacks(cfg.agents?.defaults?.model); + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(existingFallbacks ? { fallbacks: existingFallbacks } : undefined), + primary, + }, + }, + }, + }; +} + +export function applyProviderConfigWithDefaultModels( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + api: ModelApi; + baseUrl: string; + defaultModels: ModelDefinitionConfig[]; + defaultModelId?: string; + }, +): OpenClawConfig { + const providerState = resolveProviderModelMergeState(cfg, params.providerId); + + const defaultModels = params.defaultModels; + const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id; + const hasDefaultModel = defaultModelId + ? providerState.existingModels.some((model) => model.id === defaultModelId) + : true; + const mergedModels = + providerState.existingModels.length > 0 + ? hasDefaultModel || defaultModels.length === 0 + ? providerState.existingModels + : [...providerState.existingModels, ...defaultModels] + : defaultModels; + return applyProviderConfigWithMergedModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + providerState, + api: params.api, + baseUrl: params.baseUrl, + mergedModels, + fallbackModels: defaultModels, + }); +} + +export function applyProviderConfigWithDefaultModel( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + api: ModelApi; + baseUrl: string; + defaultModel: ModelDefinitionConfig; + defaultModelId?: string; + }, +): OpenClawConfig { + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + api: params.api, + baseUrl: params.baseUrl, + defaultModels: [params.defaultModel], + defaultModelId: params.defaultModelId ?? params.defaultModel.id, + }); +} + +export function applyProviderConfigWithModelCatalog( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + api: ModelApi; + baseUrl: string; + catalogModels: ModelDefinitionConfig[]; + }, +): OpenClawConfig { + const providerState = resolveProviderModelMergeState(cfg, params.providerId); + const catalogModels = params.catalogModels; + const mergedModels = + providerState.existingModels.length > 0 + ? [ + ...providerState.existingModels, + ...catalogModels.filter( + (model) => !providerState.existingModels.some((existing) => existing.id === model.id), + ), + ] + : catalogModels; + return applyProviderConfigWithMergedModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + providerState, + api: params.api, + baseUrl: params.baseUrl, + mergedModels, + fallbackModels: catalogModels, + }); +} + +type ProviderModelMergeState = { + providers: Record; + existingProvider?: ModelProviderConfig; + existingModels: ModelDefinitionConfig[]; +}; + +function resolveProviderModelMergeState( + cfg: OpenClawConfig, + providerId: string, +): ProviderModelMergeState { + const providers = { ...cfg.models?.providers } as Record; + const existingProviderKey = findNormalizedProviderKey(providers, providerId); + const existingProvider = + existingProviderKey !== undefined + ? (providers[existingProviderKey] as ModelProviderConfig | undefined) + : undefined; + const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) + ? existingProvider.models + : []; + if (existingProviderKey && existingProviderKey !== providerId) { + delete providers[existingProviderKey]; + } + return { providers, existingProvider, existingModels }; +} + +function applyProviderConfigWithMergedModels( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + providerState: ProviderModelMergeState; + api: ModelApi; + baseUrl: string; + mergedModels: ModelDefinitionConfig[]; + fallbackModels: ModelDefinitionConfig[]; + }, +): OpenClawConfig { + params.providerState.providers[params.providerId] = buildProviderConfig({ + existingProvider: params.providerState.existingProvider, + api: params.api, + baseUrl: params.baseUrl, + mergedModels: params.mergedModels, + fallbackModels: params.fallbackModels, + }); + return applyOnboardAuthAgentModelsAndProviders(cfg, { + agentModels: params.agentModels, + providers: params.providerState.providers, + }); +} + +function buildProviderConfig(params: { + existingProvider: ModelProviderConfig | undefined; + api: ModelApi; + baseUrl: string; + mergedModels: ModelDefinitionConfig[]; + fallbackModels: ModelDefinitionConfig[]; +}): ModelProviderConfig { + const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as { + apiKey?: string; + }; + const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined; + + return { + ...existingProviderRest, + baseUrl: params.baseUrl, + api: params.api, + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels, + }; +}