diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index 4fb12669a..8363dc8f0 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -6,7 +6,6 @@ import { BEDROCK_MANTLE_OPENAI_OPENCODE_PROVIDER_ID, BEDROCK_MANTLE_OPENCODE_PROVIDER_ID, buildInferenceGatewayOpenCodeBaseUrl, - buildOpenAiCompatibleProviderInstance, buildOpenCodeModelReasoningOptions, CHATGPT_FAST_MODE_ENV_VAR_NAME, CHATGPT_GATEWAY_PROVIDER_ID, @@ -17,7 +16,7 @@ import { getInferenceGatewayProvider, getInferenceGatewayProviderByEnvVarName, getMcpIntegration, - getOpenAiCompatibleProviderInstance, + getOpenAiCompatibleRuntimeConfigs, INFERENCE_GATEWAY_CHATGPT_ENV_VAR_NAME, INFERENCE_GATEWAY_GITHUB_COPILOT_ENV_VAR_NAME, INFERENCE_GATEWAY_KEYS_ENV_VAR_NAME, @@ -27,15 +26,12 @@ import { XAI_OPENCODE_PROVIDER_ID, type InferenceGatewayProvider, isConfiguredEnvValue, - isOpenAiCompatibleProviderId, isTaskModelIdDisabled, - listOpenAiCompatibleProviderInstancesFromEnvNames, + mergeOpenAiCompatibleProviderConfig, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, normalizeOptionalReasoningEffort, - OPENAI_COMPATIBLE_PROVIDER_ID, - type OpenAiCompatibleProviderInstance, parseInferenceGatewayKeys, renderManualSkillMarkdown, resolveOpenRouterVariantModelAlias, @@ -87,152 +83,6 @@ const AZURE_COGNITIVE_SERVICES_PROVIDER_ID = 'azure-cognitive-services'; const AZURE_COGNITIVE_SERVICES_API_KEY_ENV_VAR_NAME = 'AZURE_COGNITIVE_SERVICES_API_KEY'; -/** Fallback direct-mode base URL for default and named OpenAI-compatible ids. */ -const OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL = 'http://127.0.0.1:4000/v1'; - -const DEFAULT_OPENAI_COMPATIBLE_INSTANCE = - buildOpenAiCompatibleProviderInstance(null); - -/** - * Static OpenAI-compatible-style endpoint providers. Catalog entry names and - * env vars for the built-in `openai-compatible` id come from `@roomote/types`; - * ollama/vllm/litellm stay local here. Named `openai-compatible-` - * connections are discovered dynamically via the shared helpers. - */ -const STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS = { - [OPENAI_COMPATIBLE_PROVIDER_ID]: { - name: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.label, - baseUrlEnvVarName: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.baseUrlEnvVarName, - fallbackBaseUrl: OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL, - apiKeyEnvVarName: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.apiKeyEnvVarName as - | string - | undefined, - keyless: false, - // Arbitrary endpoints must not inherit OPENAI_* credentials. When the - // optional dedicated key is blank, omit the API key entirely so keyless - // servers that reject bearer auth match setup qualification behavior. - allowOpenAiEnvFallback: false, - }, - ollama: { - name: 'Ollama', - baseUrlEnvVarName: 'OLLAMA_BASE_URL', - fallbackBaseUrl: 'http://127.0.0.1:11434/v1', - apiKeyEnvVarName: undefined as string | undefined, - keyless: true, - allowOpenAiEnvFallback: false, - }, - vllm: { - name: 'vLLM', - baseUrlEnvVarName: 'VLLM_BASE_URL', - fallbackBaseUrl: 'http://127.0.0.1:8000/v1', - apiKeyEnvVarName: 'VLLM_API_KEY' as string | undefined, - keyless: false, - allowOpenAiEnvFallback: true, - }, - litellm: { - name: 'LiteLLM', - baseUrlEnvVarName: 'LITELLM_BASE_URL', - fallbackBaseUrl: 'http://127.0.0.1:4000/v1', - apiKeyEnvVarName: 'LITELLM_API_KEY' as string | undefined, - keyless: false, - allowOpenAiEnvFallback: true, - }, -} as const; - -type StaticOpenAiCompatibleProviderId = - keyof typeof STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS; - -type OpenAiCompatibleProviderRuntimeConfig = { - name: string; - baseUrlEnvVarName: string; - fallbackBaseUrl: string; - apiKeyEnvVarName: string | undefined; - keyless: boolean; - allowOpenAiEnvFallback: boolean; -}; - -function toNamedOpenAiCompatibleRuntimeConfig( - instance: OpenAiCompatibleProviderInstance, -): OpenAiCompatibleProviderRuntimeConfig { - return { - name: instance.label, - baseUrlEnvVarName: instance.baseUrlEnvVarName, - fallbackBaseUrl: OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL, - apiKeyEnvVarName: instance.apiKeyEnvVarName, - keyless: false, - allowOpenAiEnvFallback: false, - }; -} - -function resolveNamedOpenAiCompatibleInstance( - providerId: string, - runtimeEnv: Record, -): OpenAiCompatibleProviderInstance | null { - const instance = getOpenAiCompatibleProviderInstance(providerId); - if (!instance?.slug) { - return null; - } - - if (!instance.labelEnvVarName) { - return instance; - } - - const label = runtimeEnv[instance.labelEnvVarName]?.trim(); - if (!label) { - return instance; - } - - return getOpenAiCompatibleProviderInstance(providerId, { label }) ?? instance; -} - -function getOpenAiCompatibleRuntimeConfigs( - modelIds: Array, - runtimeEnv: Record, -): Map { - const configs = new Map(); - - for (const [providerId, provider] of Object.entries( - STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS, - ) as Array< - [StaticOpenAiCompatibleProviderId, OpenAiCompatibleProviderRuntimeConfig] - >) { - configs.set(providerId, provider); - } - - const candidateProviderIds = new Set(); - - for (const modelId of modelIds) { - const providerId = modelId?.trim().split('/')[0]; - if (providerId && isOpenAiCompatibleProviderId(providerId)) { - candidateProviderIds.add(providerId); - } - } - - for (const instance of listOpenAiCompatibleProviderInstancesFromEnvNames( - Object.keys(runtimeEnv), - )) { - candidateProviderIds.add(instance.id); - } - - for (const providerId of candidateProviderIds) { - if (configs.has(providerId)) { - continue; - } - - const instance = resolveNamedOpenAiCompatibleInstance( - providerId, - runtimeEnv, - ); - if (!instance) { - continue; - } - - configs.set(providerId, toNamedOpenAiCompatibleRuntimeConfig(instance)); - } - - return configs; -} - /** * OpenRouter identifies the calling application through the `HTTP-Referer` * and `X-Title` request headers rather than the standard `User-Agent`. @@ -935,97 +785,6 @@ function toBedrockMantleRuntimeModelId(modelId: string): string { : modelId; } -function mergeOpenAiCompatibleProviderConfig( - providerConfig: Record, - runtimeEnv: Record, - modelIds: Array, - visionModel?: string, -): Record { - let merged = providerConfig; - const runtimeConfigs = getOpenAiCompatibleRuntimeConfigs( - modelIds, - runtimeEnv, - ); - - for (const [providerId, provider] of runtimeConfigs) { - const prefix = `${providerId}/`; - const modelIdsForProvider = [ - ...new Set( - modelIds.flatMap((modelId) => { - const normalized = modelId?.trim(); - - return normalized?.startsWith(prefix) - ? [normalized.slice(prefix.length)] - : []; - }), - ), - ]; - - if (modelIdsForProvider.length === 0) { - continue; - } - - const existingProvider = asRecord(merged[providerId]); - const existingOptions = asRecord(existingProvider.options); - const existingModels = asRecord(existingProvider.models); - const directApiKey = provider.apiKeyEnvVarName - ? runtimeEnv[provider.apiKeyEnvVarName]?.trim() - : undefined; - const baseURL = - runtimeEnv[provider.baseUrlEnvVarName]?.trim() || - (provider.allowOpenAiEnvFallback - ? runtimeEnv.OPENAI_BASE_URL?.trim() - : '') || - provider.fallbackBaseUrl; - const apiKeyOptions = directApiKey - ? { apiKey: `{env:${provider.apiKeyEnvVarName}}` } - : provider.keyless - ? { apiKey: 'ollama' } - : provider.allowOpenAiEnvFallback && runtimeEnv.OPENAI_API_KEY?.trim() - ? { apiKey: '{env:OPENAI_API_KEY}' } - : {}; - - merged = { - ...merged, - [providerId]: { - ...existingProvider, - npm: '@ai-sdk/openai-compatible', - name: provider.name, - options: { - ...existingOptions, - baseURL, - // OpenAI-compatible clients require an API key even though Ollama - // itself accepts unauthenticated requests. - ...apiKeyOptions, - }, - models: { - ...existingModels, - ...Object.fromEntries( - modelIdsForProvider.map((modelId) => [ - modelId, - { - name: modelId, - ...(visionModel === `${providerId}/${modelId}` - ? { - attachment: true, - modalities: { - input: ['text', 'image'], - output: ['text'], - }, - } - : {}), - ...asRecord(existingModels[modelId]), - }, - ]), - ), - }, - }, - }; - } - - return merged; -} - /** * When the dequeue env carries an inference gateway URL, rebase each * gateway-covered provider that a selected model uses onto the gateway. The diff --git a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts index faa24b9a4..22a7903e5 100644 --- a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts +++ b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts @@ -20,6 +20,8 @@ describe('buildOpenCodeCliEnv', () => { 'R_MODEL_REASONING_EFFORT', 'R_SMALL_MODEL_REASONING_EFFORT', 'R_CHATGPT_FAST_MODE', + 'LITELLM_BASE_URL', + 'LITELLM_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'MISTRAL_API_KEY', 'BASH_ENV', @@ -58,6 +60,49 @@ describe('buildOpenCodeCliEnv', () => { }); }); + it('materializes LiteLLM provider config for restricted helper inference', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'litellm/qwen3.6:35b-unsloth', + R_SMALL_MODEL: 'litellm/coding', + LITELLM_BASE_URL: 'https://litellm.example.com/v1', + LITELLM_API_KEY: 'secret', + }); + + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}')).toEqual({ + model: 'litellm/qwen3.6:35b-unsloth', + small_model: 'litellm/coding', + provider: { + litellm: { + npm: '@ai-sdk/openai-compatible', + name: 'LiteLLM', + options: { + baseURL: 'https://litellm.example.com/v1', + apiKey: '{env:LITELLM_API_KEY}', + }, + models: { + 'qwen3.6:35b-unsloth': { name: 'qwen3.6:35b-unsloth' }, + coding: { name: 'coding' }, + }, + }, + }, + permission: NON_TASK_TOOL_PERMISSION_DENIALS, + }); + }); + + it('does not infer model ownership from LiteLLM endpoint availability', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'qwen3.6:35b-unsloth', + LITELLM_BASE_URL: 'https://litellm.example.com/v1', + LITELLM_API_KEY: 'secret', + }); + + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}')).toEqual({ + model: 'qwen3.6:35b-unsloth', + small_model: 'qwen3.6:35b-unsloth', + permission: NON_TASK_TOOL_PERMISSION_DENIALS, + }); + }); + it('applies per-role reasoning options to the model-backed config', () => { const env = buildOpenCodeCliEnv({ R_MODEL: 'openrouter/openai/gpt-5.4', diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts index fa015f1bf..ddcad38fa 100644 --- a/packages/cloud-agents/src/server/opencode-runtime.ts +++ b/packages/cloud-agents/src/server/opencode-runtime.ts @@ -7,6 +7,7 @@ import { CHATGPT_FAST_MODE_ENV_VAR_NAME, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, isTaskModelIdDisabled, + mergeOpenAiCompatibleProviderConfig, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, @@ -86,9 +87,10 @@ function buildModelBackedOpenCodeConfigContent( smallModel, ]) : providerReasoningConfig; - const providerConfig = mergeOpenRouterVariantAliasModels( - providerModelConfig, - variantAliases, + const providerConfig = mergeOpenAiCompatibleProviderConfig( + mergeOpenRouterVariantAliasModels(providerModelConfig, variantAliases), + env, + [model, smallModel], ); return JSON.stringify({ diff --git a/packages/types/src/__tests__/opencode-provider-config.test.ts b/packages/types/src/__tests__/opencode-provider-config.test.ts new file mode 100644 index 000000000..c79f77e61 --- /dev/null +++ b/packages/types/src/__tests__/opencode-provider-config.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeOpenAiCompatibleProviderConfig } from '../opencode-provider-config'; + +describe('mergeOpenAiCompatibleProviderConfig', () => { + it('materializes LiteLLM provider metadata for selected models', () => { + expect( + mergeOpenAiCompatibleProviderConfig( + {}, + { + LITELLM_BASE_URL: 'https://litellm.example.com/v1', + LITELLM_API_KEY: 'secret', + }, + ['litellm/qwen3.6:35b-unsloth', 'litellm/coding'], + ), + ).toEqual({ + litellm: { + npm: '@ai-sdk/openai-compatible', + name: 'LiteLLM', + options: { + baseURL: 'https://litellm.example.com/v1', + apiKey: '{env:LITELLM_API_KEY}', + }, + models: { + 'qwen3.6:35b-unsloth': { name: 'qwen3.6:35b-unsloth' }, + coding: { name: 'coding' }, + }, + }, + }); + }); + + it('preserves existing model options for named OpenAI-compatible providers', () => { + expect( + mergeOpenAiCompatibleProviderConfig( + { + 'openai-compatible-company-proxy': { + models: { 'gpt-4o': { options: { temperature: 0 } } }, + }, + }, + { + OPENAI_COMPATIBLE_COMPANY_PROXY_BASE_URL: + 'https://proxy.example.com/v1', + OPENAI_COMPATIBLE_COMPANY_PROXY_API_KEY: 'secret', + OPENAI_COMPATIBLE_COMPANY_PROXY_LABEL: 'Corp Proxy', + }, + ['openai-compatible-company-proxy/gpt-4o'], + ), + ).toMatchObject({ + 'openai-compatible-company-proxy': { + npm: '@ai-sdk/openai-compatible', + name: 'OpenAI-compatible (Corp Proxy)', + options: { + baseURL: 'https://proxy.example.com/v1', + apiKey: '{env:OPENAI_COMPATIBLE_COMPANY_PROXY_API_KEY}', + }, + models: { + 'gpt-4o': { name: 'gpt-4o', options: { temperature: 0 } }, + }, + }, + }); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1c934e213..524d23fd4 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -42,6 +42,7 @@ export * from './model-provider-config'; export * from './openai-compatible-providers'; export * from './recommended-task-models'; export * from './opencode-openrouter-variants'; +export * from './opencode-provider-config'; export * from './opencode-reasoning'; export * from './mcp-oauth'; export * from './mcp-response-parsing'; diff --git a/packages/types/src/opencode-provider-config.ts b/packages/types/src/opencode-provider-config.ts new file mode 100644 index 000000000..6792ebe1e --- /dev/null +++ b/packages/types/src/opencode-provider-config.ts @@ -0,0 +1,240 @@ +import { + buildOpenAiCompatibleProviderInstance, + getOpenAiCompatibleProviderInstance, + isOpenAiCompatibleProviderId, + listOpenAiCompatibleProviderInstancesFromEnvNames, + OPENAI_COMPATIBLE_PROVIDER_ID, + type OpenAiCompatibleProviderInstance, +} from './openai-compatible-providers'; + +/** Fallback direct-mode base URL for default and named OpenAI-compatible ids. */ +const OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL = 'http://127.0.0.1:4000/v1'; + +const DEFAULT_OPENAI_COMPATIBLE_INSTANCE = + buildOpenAiCompatibleProviderInstance(null); + +const STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS = { + [OPENAI_COMPATIBLE_PROVIDER_ID]: { + name: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.label, + baseUrlEnvVarName: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.baseUrlEnvVarName, + fallbackBaseUrl: OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL, + apiKeyEnvVarName: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.apiKeyEnvVarName as + | string + | undefined, + keyless: false, + // Arbitrary endpoints must not inherit OPENAI_* credentials. + allowOpenAiEnvFallback: false, + }, + ollama: { + name: 'Ollama', + baseUrlEnvVarName: 'OLLAMA_BASE_URL', + fallbackBaseUrl: 'http://127.0.0.1:11434/v1', + apiKeyEnvVarName: undefined as string | undefined, + keyless: true, + allowOpenAiEnvFallback: false, + }, + vllm: { + name: 'vLLM', + baseUrlEnvVarName: 'VLLM_BASE_URL', + fallbackBaseUrl: 'http://127.0.0.1:8000/v1', + apiKeyEnvVarName: 'VLLM_API_KEY' as string | undefined, + keyless: false, + allowOpenAiEnvFallback: true, + }, + litellm: { + name: 'LiteLLM', + baseUrlEnvVarName: 'LITELLM_BASE_URL', + fallbackBaseUrl: 'http://127.0.0.1:4000/v1', + apiKeyEnvVarName: 'LITELLM_API_KEY' as string | undefined, + keyless: false, + allowOpenAiEnvFallback: true, + }, +} as const; + +type StaticOpenAiCompatibleProviderId = + keyof typeof STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS; + +type OpenAiCompatibleProviderRuntimeConfig = { + name: string; + baseUrlEnvVarName: string; + fallbackBaseUrl: string; + apiKeyEnvVarName: string | undefined; + keyless: boolean; + allowOpenAiEnvFallback: boolean; +}; + +type RuntimeEnv = Readonly>; + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +function toNamedOpenAiCompatibleRuntimeConfig( + instance: OpenAiCompatibleProviderInstance, +): OpenAiCompatibleProviderRuntimeConfig { + return { + name: instance.label, + baseUrlEnvVarName: instance.baseUrlEnvVarName, + fallbackBaseUrl: OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL, + apiKeyEnvVarName: instance.apiKeyEnvVarName, + keyless: false, + allowOpenAiEnvFallback: false, + }; +} + +function resolveNamedOpenAiCompatibleInstance( + providerId: string, + runtimeEnv: RuntimeEnv, +): OpenAiCompatibleProviderInstance | null { + const instance = getOpenAiCompatibleProviderInstance(providerId); + if (!instance?.slug) { + return null; + } + + if (!instance.labelEnvVarName) { + return instance; + } + + const label = runtimeEnv[instance.labelEnvVarName]?.trim(); + if (!label) { + return instance; + } + + return getOpenAiCompatibleProviderInstance(providerId, { label }) ?? instance; +} + +export function getOpenAiCompatibleRuntimeConfigs( + modelIds: Array, + runtimeEnv: RuntimeEnv, +): Map { + const configs = new Map(); + + for (const [providerId, provider] of Object.entries( + STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS, + ) as Array< + [StaticOpenAiCompatibleProviderId, OpenAiCompatibleProviderRuntimeConfig] + >) { + configs.set(providerId, provider); + } + + const candidateProviderIds = new Set(); + + for (const modelId of modelIds) { + const providerId = modelId?.trim().split('/')[0]; + if (providerId && isOpenAiCompatibleProviderId(providerId)) { + candidateProviderIds.add(providerId); + } + } + + for (const instance of listOpenAiCompatibleProviderInstancesFromEnvNames( + Object.keys(runtimeEnv), + )) { + candidateProviderIds.add(instance.id); + } + + for (const providerId of candidateProviderIds) { + if (configs.has(providerId)) { + continue; + } + + const instance = resolveNamedOpenAiCompatibleInstance( + providerId, + runtimeEnv, + ); + if (instance) { + configs.set(providerId, toNamedOpenAiCompatibleRuntimeConfig(instance)); + } + } + + return configs; +} + +export function mergeOpenAiCompatibleProviderConfig( + providerConfig: Record, + runtimeEnv: RuntimeEnv, + modelIds: Array, + visionModel?: string, +): Record { + let merged = providerConfig; + const runtimeConfigs = getOpenAiCompatibleRuntimeConfigs( + modelIds, + runtimeEnv, + ); + + for (const [providerId, provider] of runtimeConfigs) { + const prefix = `${providerId}/`; + const modelIdsForProvider = [ + ...new Set( + modelIds.flatMap((modelId) => { + const normalized = modelId?.trim(); + return normalized?.startsWith(prefix) + ? [normalized.slice(prefix.length)] + : []; + }), + ), + ]; + + if (modelIdsForProvider.length === 0) { + continue; + } + + const existingProvider = asRecord(merged[providerId]); + const existingOptions = asRecord(existingProvider.options); + const existingModels = asRecord(existingProvider.models); + const directApiKey = provider.apiKeyEnvVarName + ? runtimeEnv[provider.apiKeyEnvVarName]?.trim() + : undefined; + const baseURL = + runtimeEnv[provider.baseUrlEnvVarName]?.trim() || + (provider.allowOpenAiEnvFallback + ? runtimeEnv.OPENAI_BASE_URL?.trim() + : '') || + provider.fallbackBaseUrl; + const apiKeyOptions = directApiKey + ? { apiKey: `{env:${provider.apiKeyEnvVarName}}` } + : provider.keyless + ? { apiKey: 'ollama' } + : provider.allowOpenAiEnvFallback && runtimeEnv.OPENAI_API_KEY?.trim() + ? { apiKey: '{env:OPENAI_API_KEY}' } + : {}; + + merged = { + ...merged, + [providerId]: { + ...existingProvider, + npm: '@ai-sdk/openai-compatible', + name: provider.name, + options: { + ...existingOptions, + baseURL, + ...apiKeyOptions, + }, + models: { + ...existingModels, + ...Object.fromEntries( + modelIdsForProvider.map((modelId) => [ + modelId, + { + name: modelId, + ...(visionModel === `${providerId}/${modelId}` + ? { + attachment: true, + modalities: { + input: ['text', 'image'], + output: ['text'], + }, + } + : {}), + ...asRecord(existingModels[modelId]), + }, + ]), + ), + }, + }, + }; + } + + return merged; +}