diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 368eae0a..9affcc9c 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -21,6 +21,28 @@ export type McpServerConfig = { command: string; args?: string[]; env?: Record; + /** + * Per-server connect/discovery timeout in ms. + * Defaults to DEEPCODE_MCP_TIMEOUT env (or 30_000). Lets fast servers use a + * tight timeout while slow servers get more room, instead of one global value. + */ + connectTimeoutMs?: number; + /** If true, startup fails when this server cannot be reached (otherwise best-effort). */ + required?: boolean; + /** + * If true, the server is NOT connected at startup; it connects lazily on the + * first tool call that routes to it (via ensureConnected in executeMcpTool) + * or via reconnect. Keeps headless startup fast when a server is rarely used. + */ + deferLoading?: boolean; + /** If set, only these tools (exact server-side names) are exposed. */ + enabledTools?: string[]; + /** If set, these tools (exact server-side names) are hidden. */ + disabledTools?: string[]; + /** Remote MCP (streamable-http/SSE): set url instead of command. */ + url?: string; + /** Extra HTTP headers for remote MCP servers. */ + headers?: Record; }; export type PermissionScope = @@ -81,6 +103,86 @@ export type ResolvedStatusLineSettings = { providers: StatusLineProviderConfig[]; }; +/** + * One configured LLM provider entry (pi-ai-style multi-provider layer). + * `type` mirrors the pi-ai provider kinds: "openai" (OpenAI-compatible, + * incl. deepseek) or "anthropic" (Anthropic Messages API, incl. + * deepseek-anthropic compatible endpoints). + */ +export type ProviderConfig = { + type?: string; + apiBase?: string; + apiKey?: string; + models?: string[]; +}; + +export type ProviderSettings = { + active?: string; + providers?: Record; +}; + +/** Normalized provider aliases → provider kind. */ +export type ResolvedProvider = { + name: string; + kind: "openai" | "anthropic"; + apiBase: string; + apiKey: string | undefined; + models: string[]; +}; + +const PROVIDER_KIND_BY_TYPE: Record = { + openai: "openai", + openai_compat: "openai", + deepseek: "openai", + ollama: "openai", + anthropic: "anthropic", + deepseek_anthropic: "anthropic", + deepseek_anthropic_compat: "anthropic", +}; + +export function normalizeProviderKind(type: string | undefined): "openai" | "anthropic" { + const key = (type ?? "").trim().toLowerCase().replace(/-/g, "_"); + return PROVIDER_KIND_BY_TYPE[key] ?? "openai"; +} + +/** + * Resolve the active provider from the `provider` config block (pi-ai style). + * Falls back to the legacy env-driven path (env.API_KEY/BASE_URL) when no + * provider block is configured. `active` may be a provider name or alias. + */ +export function resolveActiveProvider( + userSettings: { provider?: ProviderSettings } | null | undefined, + projectSettings: { provider?: ProviderSettings } | null | undefined, + legacyApiKey: string | undefined, + legacyBaseURL: string | undefined +): ResolvedProvider | undefined { + const merged: ProviderSettings = { + active: projectSettings?.provider?.active ?? userSettings?.provider?.active, + providers: { + ...(userSettings?.provider?.providers ?? {}), + ...(projectSettings?.provider?.providers ?? {}), + }, + }; + const active = merged.active?.trim(); + if (!active || !merged.providers) { + return undefined; + } + // Aliases: active may name a provider directly or use "name:type" form. + const config = + merged.providers[active] ?? + Object.values(merged.providers).find((p) => normalizeProviderKind(p?.type) === normalizeProviderKind(active)); + if (!config) { + return undefined; + } + return { + name: active, + kind: normalizeProviderKind(config.type), + apiBase: config.apiBase ?? legacyBaseURL ?? "", + apiKey: config.apiKey ?? legacyApiKey, + models: config.models ?? [], + }; +} + export type DeepcodingSettings = { env?: DeepcodingEnv; contextWindow?: number | string; @@ -95,6 +197,7 @@ export type DeepcodingSettings = { webSearchTool?: string; multimodal?: MultimodalMode; mcpServers?: Record; + provider?: ProviderSettings; permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; statusline?: StatusLineSettings; @@ -116,6 +219,8 @@ export type ResolvedDeepcodingSettings = { webSearchTool?: string; multimodal: MultimodalMode; mcpServers?: Record; + /** Resolved active provider (pi-ai style); falls back to the legacy env path. */ + provider?: ResolvedProvider; permissions: Required; enabledSkills: EnabledSkillsSettings; statusline: ResolvedStatusLineSettings; @@ -505,9 +610,18 @@ function mergeMcpServers( ...systemEnv, ...systemMcpEnv, }; + // Whole-field merge: project wins, user falls back (same precedence as + // command/args). Booleans use ?? so an explicit `false` is preserved. const config: McpServerConfig = { command, args: projectConfig?.args ?? userConfig?.args, + connectTimeoutMs: projectConfig?.connectTimeoutMs ?? userConfig?.connectTimeoutMs, + required: projectConfig?.required ?? userConfig?.required, + deferLoading: projectConfig?.deferLoading ?? userConfig?.deferLoading, + enabledTools: projectConfig?.enabledTools ?? userConfig?.enabledTools, + disabledTools: projectConfig?.disabledTools ?? userConfig?.disabledTools, + url: projectConfig?.url ?? userConfig?.url, + headers: projectConfig?.headers ?? userConfig?.headers, }; if (Object.keys(env).length > 0) { config.env = env; @@ -607,6 +721,13 @@ export function resolveSettingsSources( resolveMultimodalMode(userEnv.MULTIMODAL) ?? "default"; + const provider = resolveActiveProvider( + userSettings, + projectSettings, + trimString(env.API_KEY) || undefined, + trimString(env.BASE_URL) || defaults.baseURL + ); + return { env, apiKey: trimString(env.API_KEY) || undefined, @@ -623,6 +744,7 @@ export function resolveSettingsSources( webSearchTool: webSearchTool || undefined, multimodal, mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), + provider, permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), statusline: mergeStatusLine(userSettings, projectSettings), diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index cd5b1461..640d65eb 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -496,6 +496,57 @@ test("resolveSettingsSources merges MCP env with documented priority", () => { }); }); +test("resolveSettingsSources preserves deferLoading and extra MCP fields (project wins, user falls back)", () => { + const resolved = resolveSettingsSources( + { + mcpServers: { + lazy: { + command: "node", + args: ["user-lazy.js"], + deferLoading: true, + required: false, + enabledTools: ["user_tool"], + connectTimeoutMs: 5000, + }, + urlOnly: { + command: "node", + url: "https://user.example.com/sse", + headers: { Authorization: "user-token" }, + }, + }, + }, + { + mcpServers: { + lazy: { + command: "python", + deferLoading: false, + disabledTools: ["project_hidden"], + }, + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + {} + ); + + // Project field wins when set. + assert.equal(resolved.mcpServers?.lazy?.command, "python"); + assert.equal(resolved.mcpServers?.lazy?.deferLoading, false); + assert.deepEqual(resolved.mcpServers?.lazy?.disabledTools, ["project_hidden"]); + // User field falls back when project does not set it. + assert.deepEqual(resolved.mcpServers?.lazy?.args, ["user-lazy.js"]); + assert.equal(resolved.mcpServers?.lazy?.required, false); + assert.deepEqual(resolved.mcpServers?.lazy?.enabledTools, ["user_tool"]); + assert.equal(resolved.mcpServers?.lazy?.connectTimeoutMs, 5000); + // URL / headers survive the merge untouched. + assert.equal(resolved.mcpServers?.urlOnly?.url, "https://user.example.com/sse"); + assert.deepEqual(resolved.mcpServers?.urlOnly?.headers, { + Authorization: "user-token", + }); +}); + test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { const resolved = resolveSettings( {