Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions packages/core/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ export type McpServerConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
/**
* 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<string, string>;
};

export type PermissionScope =
Expand Down Expand Up @@ -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<string, ProviderConfig>;
};

/** 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<string, "openai" | "anthropic"> = {
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;
Expand All @@ -95,6 +197,7 @@ export type DeepcodingSettings = {
webSearchTool?: string;
multimodal?: MultimodalMode;
mcpServers?: Record<string, McpServerConfig>;
provider?: ProviderSettings;
permissions?: PermissionSettings;
enabledSkills?: EnabledSkillsSettings;
statusline?: StatusLineSettings;
Expand All @@ -116,6 +219,8 @@ export type ResolvedDeepcodingSettings = {
webSearchTool?: string;
multimodal: MultimodalMode;
mcpServers?: Record<string, McpServerConfig>;
/** Resolved active provider (pi-ai style); falls back to the legacy env path. */
provider?: ResolvedProvider;
permissions: Required<PermissionSettings>;
enabledSkills: EnabledSkillsSettings;
statusline: ResolvedStatusLineSettings;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/tests/settings-and-notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down