devlensio 0.4.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +212 -74
  3. package/dist/config/index.d.ts +14 -2
  4. package/dist/config/index.js +77 -2
  5. package/dist/config/providers/catalog.d.ts +27 -0
  6. package/dist/config/providers/catalog.js +130 -0
  7. package/dist/config/providers/file.d.ts +4 -2
  8. package/dist/config/providers/file.js +198 -32
  9. package/dist/config/providers/paths.d.ts +2 -0
  10. package/dist/config/providers/paths.js +4 -0
  11. package/dist/config/providers/providers.default.d.ts +3 -0
  12. package/dist/config/providers/providers.default.js +12 -0
  13. package/dist/config/providers/request.js +21 -9
  14. package/dist/config/types.d.ts +25 -2
  15. package/dist/config/types.js +22 -7
  16. package/dist/config/writer.d.ts +8 -2
  17. package/dist/config/writer.js +59 -12
  18. package/dist/filesystem/backendRoutes.d.ts +2 -2
  19. package/dist/filesystem/backendRoutes.js +317 -18
  20. package/dist/filesystem/index.js +7 -3
  21. package/dist/filesystem/index.test.js +274 -0
  22. package/dist/fingerprint/detectors.js +8 -2
  23. package/dist/fingerprint/index.test.js +54 -0
  24. package/dist/graph/edges/callEdges.js +12 -2
  25. package/dist/index.d.ts +6 -1
  26. package/dist/index.js +5 -1
  27. package/dist/parser/extractors/components.js +3 -0
  28. package/dist/parser/extractors/functions.d.ts +1 -0
  29. package/dist/parser/extractors/functions.js +11 -2
  30. package/dist/parser/extractors/hooks.js +5 -0
  31. package/dist/parser/index.test.js +40 -0
  32. package/dist/summarizer/providers/anthropic.d.ts +2 -1
  33. package/dist/summarizer/providers/anthropic.js +3 -2
  34. package/dist/summarizer/providers/index.js +19 -35
  35. package/dist/summarizer/providers/models.d.ts +11 -0
  36. package/dist/summarizer/providers/models.js +113 -0
  37. package/dist/summarizer/providers/openai.d.ts +2 -1
  38. package/dist/summarizer/providers/openai.js +2 -1
  39. package/dist/summarizer/providers/types.d.ts +1 -6
  40. package/dist/types.d.ts +2 -2
  41. package/package.json +1 -1
  42. package/dist/summarizer/providers/gemini.d.ts +0 -9
  43. package/dist/summarizer/providers/gemini.js +0 -79
  44. package/dist/summarizer/providers/ollama.d.ts +0 -9
  45. package/dist/summarizer/providers/ollama.js +0 -23
  46. package/dist/summarizer/providers/openRouter.d.ts +0 -9
  47. package/dist/summarizer/providers/openRouter.js +0 -19
@@ -1,11 +1,9 @@
1
1
  import fs from "fs";
2
- import path from "path";
3
- import os from "os";
4
- import { ANTHROPIC_DEFAULTS, } from "../types.js";
2
+ import { CONFIG_FILE } from "./paths.js";
3
+ import { ANTHROPIC_DEFAULTS, makeProviderKey, } from "../types.js";
4
+ import { findProvider } from "./catalog.js";
5
+ export { CONFIG_DIR, CONFIG_FILE } from "./paths.js";
5
6
  // ─── Constants ────────────────────────────────────────────────────────────────
6
- export const CONFIG_DIR = path.join(os.homedir(), ".devlens");
7
- export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
8
- // ─── Environment Variable Names ───────────────────────────────────────────────
9
7
  //
10
8
  // For Docker users who prefer env vars over config files.
11
9
  // A Docker user running Ollama in the same network would set:
@@ -17,6 +15,7 @@ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
17
15
  export const ENV = {
18
16
  // Summarization
19
17
  LLM_PROVIDER: "DEVLENS_LLM_PROVIDER", //here DEVLENS_LLM_PROVIDER is the actual env variable
18
+ LLM_PROVIDER_NAME: "DEVLENS_LLM_PROVIDER_NAME",
20
19
  LLM_MODEL: "DEVLENS_LLM_MODEL",
21
20
  LLM_KEY: "DEVLENS_LLM_KEY",
22
21
  LLM_BASE_URL: "DEVLENS_LLM_BASE_URL",
@@ -32,7 +31,7 @@ export const ENV = {
32
31
  NEO4J_PASSWORD: "DEVLENS_NEO4J_PASSWORD",
33
32
  NEO4J_STORECODE: "NEO4J_STORE_CODE"
34
33
  };
35
- // ─── Deep Merge ───────────────────────────────────────────────────────────────
34
+ // Deep Merge
36
35
  //
37
36
  // Merges user's partial config on top of defaults.
38
37
  // Does NOT mutate either argument — returns a new object.
@@ -60,7 +59,73 @@ function deepMerge(base, partial) {
60
59
  : base.neo4j,
61
60
  };
62
61
  }
63
- // ─── Env Var Application ──────────────────────────────────────────────────────
62
+ // ─── v1→v2 Provider Migration ──────────────────────────────────────────────
63
+ // Old configs stored brand strings ("openrouter", "ollama", etc.) in
64
+ // `provider`. In v2, provider is the wire protocol and the brand goes in
65
+ // `providerName`. This maps old values via the catalog and persists the fix.
66
+ function migrateProviderConfig(config) {
67
+ const p = config.summarization.provider;
68
+ if (p === "openai" || p === "anthropic")
69
+ return config;
70
+ const entry = findProvider(p);
71
+ if (!entry)
72
+ throw new Error(`DevLens: "${p}" is not a valid provider protocol. Wire protocol must be "openai" or "anthropic". ` +
73
+ `Fix: set "provider" to "openai" and "providerName" to "${p}" in ~/.devlens/config.json`);
74
+ const migrated = { ...config, summarization: { ...config.summarization, provider: entry.protocol, providerName: entry.name } };
75
+ try {
76
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
77
+ raw.summarization = { ...raw.summarization, provider: entry.protocol };
78
+ if (!raw.summarization.providerName)
79
+ raw.summarization.providerName = entry.name;
80
+ const tmp = CONFIG_FILE + ".tmp";
81
+ fs.writeFileSync(tmp, JSON.stringify(raw, null, 2));
82
+ fs.renameSync(tmp, CONFIG_FILE);
83
+ }
84
+ catch { /* non-fatal — will re-migrate next load */ }
85
+ return migrated;
86
+ }
87
+ const VALID_LLM_PROTOCOLS = new Set(["openai", "anthropic"]);
88
+ const VALID_EMBED_PROTOCOLS = new Set(["openai", "anthropic", "openrouter", "gemini", "ollama"]);
89
+ function sanitizeProviderEnv(raw) {
90
+ if (!raw)
91
+ return undefined;
92
+ if (VALID_LLM_PROTOCOLS.has(raw))
93
+ return raw;
94
+ // It's a junk/placeholder value — skip it so the default stays in place.
95
+ return undefined;
96
+ }
97
+ function sanitizeEmbedProviderEnv(raw) {
98
+ if (!raw)
99
+ return undefined;
100
+ if (VALID_EMBED_PROTOCOLS.has(raw))
101
+ return raw;
102
+ return undefined;
103
+ }
104
+ function sanitizeApiKeyEnv(raw) {
105
+ if (!raw)
106
+ return undefined;
107
+ // Reject dot-notation field references: "embedding.apiKey", "summarization.key", etc.
108
+ if (/^[a-z]+\.[a-zA-Z]/.test(raw))
109
+ return undefined;
110
+ return raw;
111
+ }
112
+ function sanitizeBaseUrlEnv(raw) {
113
+ if (!raw)
114
+ return undefined;
115
+ if (raw.startsWith("http://") || raw.startsWith("https://"))
116
+ return raw;
117
+ // Not a URL — likely a placeholder like "embedding.baseUrl"
118
+ return undefined;
119
+ }
120
+ function sanitizeModelEnv(raw) {
121
+ if (!raw)
122
+ return undefined;
123
+ // Reject dot-notation references like "summarization.model"
124
+ if (/^[a-z]+\.[a-zA-Z]/.test(raw))
125
+ return undefined;
126
+ return raw;
127
+ }
128
+ // Env Var Application
64
129
  //
65
130
  // Applies environment variables onto an already-merged config.
66
131
  // Only fills fields that are still empty after the file merge.
@@ -78,20 +143,21 @@ function applyEnvVars(config) {
78
143
  // Only inject env var if config file didn't already set this field
79
144
  provider: s.provider !== ANTHROPIC_DEFAULTS.summarization.provider
80
145
  ? s.provider
81
- : process.env[ENV.LLM_PROVIDER] ?? s.provider,
82
- model: s.model ?? process.env[ENV.LLM_MODEL],
83
- apiKey: s.apiKey ?? process.env[ENV.LLM_KEY],
84
- baseUrl: s.baseUrl ?? process.env[ENV.LLM_BASE_URL],
146
+ : sanitizeProviderEnv(process.env[ENV.LLM_PROVIDER]) ?? s.provider,
147
+ providerName: s.providerName ?? sanitizeModelEnv(process.env[ENV.LLM_PROVIDER_NAME]),
148
+ model: s.model ?? sanitizeModelEnv(process.env[ENV.LLM_MODEL]),
149
+ apiKey: s.apiKey ?? sanitizeApiKeyEnv(process.env[ENV.LLM_KEY]),
150
+ baseUrl: s.baseUrl ?? sanitizeBaseUrlEnv(process.env[ENV.LLM_BASE_URL]),
85
151
  batchSize: s.batchSize ?? (parseInt(process.env[ENV.BATCH_SIZE] ?? "", 10) ?? s.batchSize),
86
152
  },
87
153
  embedding: {
88
154
  ...e,
89
155
  provider: e.provider !== ANTHROPIC_DEFAULTS.embedding.provider
90
156
  ? e.provider
91
- : process.env[ENV.EMBED_PROVIDER] ?? e.provider,
92
- model: e.model ?? process.env[ENV.EMBED_MODEL],
93
- apiKey: e.apiKey ?? process.env[ENV.EMBED_KEY],
94
- baseUrl: e.baseUrl ?? process.env[ENV.EMBED_BASE_URL],
157
+ : sanitizeEmbedProviderEnv(process.env[ENV.EMBED_PROVIDER]) ?? e.provider,
158
+ model: e.model ?? sanitizeModelEnv(process.env[ENV.EMBED_MODEL]),
159
+ apiKey: e.apiKey ?? sanitizeApiKeyEnv(process.env[ENV.EMBED_KEY]),
160
+ baseUrl: e.baseUrl ?? sanitizeBaseUrlEnv(process.env[ENV.EMBED_BASE_URL]),
95
161
  },
96
162
  // Neo4j: only build from env vars if config file didn't set it
97
163
  // AND all three required env vars are present
@@ -110,15 +176,14 @@ function buildNeo4jFromEnv() {
110
176
  return undefined;
111
177
  return { url, username, password, storeRawCode };
112
178
  }
113
- // ─── Validation ───────────────────────────────────────────────────────────────
179
+ // Validation
114
180
  //
115
181
  // Only validates what cannot have a sensible default.
116
- // apiKey is required for all cloud providers (anthropic, openai, openrouter, gemini).
117
- // ollama needs no apiKey — it uses baseUrl.
118
- // managed never needs an apiKey — platform provides it via request headers.
182
+ // Key requirement is resolved from the catalog's `requiresKey` per provider.
183
+ // If a providerName isn't in the catalog (custom), we default to requiring a key.
119
184
  //
120
185
  // Error messages are actionable — they tell the user exactly how to fix the problem.
121
- const PROVIDERS_NEEDING_KEY = new Set([
186
+ const EMBEDDING_PROVIDERS_NEEDING_KEY = new Set([
122
187
  "anthropic",
123
188
  "openai",
124
189
  "openrouter",
@@ -126,10 +191,11 @@ const PROVIDERS_NEEDING_KEY = new Set([
126
191
  ]);
127
192
  function validate(config) {
128
193
  const { summarization, embedding } = config;
129
- // Summarization apiKey
130
- if (PROVIDERS_NEEDING_KEY.has(summarization.provider) &&
131
- !summarization.apiKey) {
132
- throw new Error(`DevLens config error: summarization.apiKey is required when provider is "${summarization.provider}".\n` +
194
+ // Summarization apiKey — resolved from catalog
195
+ const entry = findProvider(summarization.providerName ?? "");
196
+ const needsKey = entry?.requiresKey ?? true; // unknown/custom → require key
197
+ if (needsKey && !summarization.apiKey) {
198
+ throw new Error(`DevLens config error: summarization.apiKey is required for "${summarization.providerName ?? summarization.provider}".\n` +
133
199
  ` Fix option 1 — add to ${CONFIG_FILE}:\n` +
134
200
  ` { "summarization": { "apiKey": "your-key-here" } }\n` +
135
201
  ` Fix option 2 — set environment variable:\n` +
@@ -142,7 +208,7 @@ function validate(config) {
142
208
  const rawFile = readFileConfig();
143
209
  const userSetEmbedding = !!rawFile.embedding?.provider;
144
210
  if (userSetEmbedding &&
145
- PROVIDERS_NEEDING_KEY.has(embedding.provider) &&
211
+ EMBEDDING_PROVIDERS_NEEDING_KEY.has(embedding.provider) &&
146
212
  !embedding.apiKey) {
147
213
  throw new Error(`DevLens config error: embedding.apiKey is required when provider is "${embedding.provider}".\n` +
148
214
  ` Fix option 1 — add to ${CONFIG_FILE}:\n` +
@@ -165,15 +231,15 @@ function validate(config) {
165
231
  }
166
232
  }
167
233
  // Ollama baseUrl format
168
- if (summarization.provider === "ollama") {
169
- const base = summarization.baseUrl ?? "http://localhost:11434";
234
+ if (summarization.providerName === "ollama") {
235
+ const base = summarization.baseUrl ?? "http://localhost:11434/v1";
170
236
  if (!base.startsWith("http://") && !base.startsWith("https://")) {
171
237
  throw new Error(`DevLens config error: summarization.baseUrl must start with http:// or https://.\n` +
172
238
  ` Got: "${base}"`);
173
239
  }
174
240
  }
175
241
  }
176
- // ─── readFileConfig ───────────────────────────────────────────────────────────
242
+ // readFileConfig
177
243
  //
178
244
  // Reads ~/.devlens/config.json and returns a PartialConfig.
179
245
  // Returns empty object if file doesn't exist — first run, caller uses defaults.
@@ -192,7 +258,57 @@ function readFileConfig() {
192
258
  ` Tip: use a JSON validator at https://jsonlint.com`);
193
259
  }
194
260
  }
195
- // ─── loadFileConfig ───────────────────────────────────────────────────────────
261
+ /** Public — reads the raw config.json as a plain object. Used by multi-provider helpers. */
262
+ export function readRawConfigFile() {
263
+ if (!fs.existsSync(CONFIG_FILE))
264
+ return {};
265
+ try {
266
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
267
+ }
268
+ catch {
269
+ return {};
270
+ }
271
+ }
272
+ // ── Multi-provider detection ──────────────────────────────────────────────
273
+ /** Returns true if the raw summarization block is in multi-provider format. */
274
+ function isMultiProviderFormat(s) {
275
+ if (!s || typeof s !== "object")
276
+ return false;
277
+ const obj = s;
278
+ return (typeof obj.active === "string" &&
279
+ obj.providers !== undefined &&
280
+ typeof obj.providers === "object" &&
281
+ !Array.isArray(obj.providers));
282
+ }
283
+ /** Extract the active provider from multi-provider storage, returning a flat summarization partial. */
284
+ function extractActiveProvider(storage) {
285
+ const entry = storage.providers[storage.active];
286
+ if (!entry) {
287
+ // Fallback: pick the first available provider
288
+ const first = Object.values(storage.providers)[0];
289
+ if (!first)
290
+ throw new Error("Multi-provider config has no provider entries.");
291
+ // Fix the active key to match what exists
292
+ storage.active = Object.keys(storage.providers)[0];
293
+ return {
294
+ provider: first.provider,
295
+ providerName: first.providerName,
296
+ model: first.model,
297
+ apiKey: first.apiKey,
298
+ baseUrl: first.baseUrl,
299
+ batchSize: first.batchSize,
300
+ };
301
+ }
302
+ return {
303
+ provider: entry.provider,
304
+ providerName: entry.providerName,
305
+ model: entry.model,
306
+ apiKey: entry.apiKey,
307
+ baseUrl: entry.baseUrl,
308
+ batchSize: entry.batchSize,
309
+ };
310
+ }
311
+ // loadFileConfig
196
312
  //
197
313
  // Public entry point — called by resolveConfig() in config/index.ts.
198
314
  //
@@ -207,9 +323,59 @@ function readFileConfig() {
207
323
  // 4. Validate — throw clear errors for anything missing or invalid
208
324
  // 5. Return fully resolved DevLensConfig — never partial, never undefined fields
209
325
  export function loadFileConfig(defaults = ANTHROPIC_DEFAULTS) {
210
- const partial = readFileConfig();
326
+ let partial = readFileConfig();
327
+ // ── Multi-provider detection & migration ─────────────────────────────────
328
+ if (partial.summarization) {
329
+ const sum = partial.summarization;
330
+ if (isMultiProviderFormat(sum)) {
331
+ // New multi-provider format — extract the active entry
332
+ const activeFlat = extractActiveProvider(sum);
333
+ partial = {
334
+ ...partial,
335
+ summarization: activeFlat,
336
+ };
337
+ }
338
+ else if (typeof sum.provider === "string" && !sum.providers) {
339
+ // Old flat format — migrate to multi-provider automatically
340
+ const protocol = sum.provider;
341
+ const providerName = sum.providerName ?? protocol;
342
+ const key = makeProviderKey(protocol, providerName);
343
+ const newStorage = {
344
+ active: key,
345
+ providers: {
346
+ [key]: {
347
+ provider: protocol,
348
+ providerName: providerName,
349
+ model: sum.model ?? "",
350
+ apiKey: sum.apiKey,
351
+ baseUrl: sum.baseUrl,
352
+ batchSize: sum.batchSize ?? 50,
353
+ },
354
+ },
355
+ };
356
+ // Write the migrated format back atomically
357
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
358
+ raw.summarization = newStorage;
359
+ const tmp = CONFIG_FILE + ".tmp";
360
+ fs.writeFileSync(tmp, JSON.stringify(raw, null, 2));
361
+ fs.renameSync(tmp, CONFIG_FILE);
362
+ // Proceed with the active entry as the flat summarization
363
+ partial = {
364
+ ...partial,
365
+ summarization: {
366
+ provider: protocol,
367
+ providerName: providerName,
368
+ model: sum.model ?? "",
369
+ apiKey: sum.apiKey,
370
+ baseUrl: sum.baseUrl,
371
+ batchSize: sum.batchSize ?? 50,
372
+ },
373
+ };
374
+ }
375
+ }
211
376
  const merged = deepMerge(defaults, partial);
212
- const withEnv = applyEnvVars(merged);
377
+ const migrated = migrateProviderConfig(merged);
378
+ const withEnv = applyEnvVars(migrated);
213
379
  validate(withEnv);
214
380
  return withEnv;
215
381
  }
@@ -0,0 +1,2 @@
1
+ export declare const CONFIG_DIR: string;
2
+ export declare const CONFIG_FILE: string;
@@ -0,0 +1,4 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ export const CONFIG_DIR = path.join(os.homedir(), ".devlens");
4
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
@@ -0,0 +1,3 @@
1
+ import type { CatalogProvider } from "./catalog.js";
2
+ export declare const DEFAULT_PROVIDERS: CatalogProvider[];
3
+ export declare const CATALOG_VERSION = 2;
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_PROVIDERS = [
2
+ { name: "deepseek", label: "DeepSeek", protocol: "openai", baseUrl: "https://api.deepseek.com", requiresKey: true },
3
+ { name: "openai", label: "OpenAI", protocol: "openai", baseUrl: "https://api.openai.com/v1", requiresKey: true },
4
+ { name: "anthropic", label: "Anthropic", protocol: "anthropic", baseUrl: "https://api.anthropic.com", requiresKey: true },
5
+ { name: "gemini", label: "Google Gemini", protocol: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", requiresKey: true },
6
+ { name: "groq", label: "Groq", protocol: "openai", baseUrl: "https://api.groq.com/openai/v1", requiresKey: true },
7
+ { name: "mistral", label: "Mistral", protocol: "openai", baseUrl: "https://api.mistral.ai/v1", requiresKey: true },
8
+ { name: "xai", label: "xAI Grok", protocol: "openai", baseUrl: "https://api.x.ai/v1", requiresKey: true },
9
+ { name: "openrouter", label: "OpenRouter", protocol: "openai", baseUrl: "https://openrouter.ai/api/v1", requiresKey: true },
10
+ { name: "ollama", label: "Ollama (local)", protocol: "openai", baseUrl: "http://localhost:11434/v1", requiresKey: false },
11
+ ];
12
+ export const CATALOG_VERSION = 2;
@@ -1,5 +1,15 @@
1
1
  import { CONFIG_HEADERS } from "../types.js";
2
- // ─ applyRequestHeaders
2
+ const VALID_LLM_PROTOCOLS = new Set(["openai", "anthropic"]);
3
+ const VALID_EMBED_PROTOCOLS = new Set(["openai", "anthropic", "openrouter", "gemini", "ollama"]);
4
+ function rejectPlaceholder(v) {
5
+ if (!v)
6
+ return undefined;
7
+ // Dot-notation field references like "embedding.provider", "summarization.key"
8
+ if (/^[a-z]+\.[a-zA-Z]/.test(v))
9
+ return undefined;
10
+ return v;
11
+ }
12
+ // ─ applyRequestHeaders
3
13
  //
4
14
  // Public — called by resolveConfig() in config/index.ts AFTER loadFileConfig().
5
15
  //
@@ -27,15 +37,16 @@ export function applyRequestHeaders(base, req) {
27
37
  }
28
38
  // Summarization overrides ─
29
39
  const provider = get(CONFIG_HEADERS.PROVIDER);
30
- const model = get(CONFIG_HEADERS.MODEL);
31
- const apiKey = get(CONFIG_HEADERS.API_KEY);
32
- const baseUrl = get(CONFIG_HEADERS.BASE_URL);
40
+ const providerName = rejectPlaceholder(get(CONFIG_HEADERS.PROVIDER_NAME));
41
+ const model = rejectPlaceholder(get(CONFIG_HEADERS.MODEL));
42
+ const apiKey = rejectPlaceholder(get(CONFIG_HEADERS.API_KEY));
43
+ const baseUrl = (() => { const v = get(CONFIG_HEADERS.BASE_URL); return v && (v.startsWith("http://") || v.startsWith("https://")) ? v : undefined; })();
33
44
  const batchSize = get(CONFIG_HEADERS.BATCH_SIZE);
34
45
  // Embedding overrides ─
35
46
  const embedProvider = get(CONFIG_HEADERS.EMBED_PROVIDER);
36
- const embedModel = get(CONFIG_HEADERS.EMBED_MODEL);
37
- const embedKey = get(CONFIG_HEADERS.EMBED_KEY);
38
- const embedBaseUrl = get(CONFIG_HEADERS.EMBED_BASE_URL);
47
+ const embedModel = rejectPlaceholder(get(CONFIG_HEADERS.EMBED_MODEL));
48
+ const embedKey = rejectPlaceholder(get(CONFIG_HEADERS.EMBED_KEY));
49
+ const embedBaseUrl = (() => { const v = get(CONFIG_HEADERS.EMBED_BASE_URL); return v && (v.startsWith("http://") || v.startsWith("https://")) ? v : undefined; })();
39
50
  // Neo4j overrides ─
40
51
  const neo4jUrl = get(CONFIG_HEADERS.NEO4J_URL);
41
52
  const neo4jUser = get(CONFIG_HEADERS.NEO4J_USER);
@@ -48,7 +59,8 @@ export function applyRequestHeaders(base, req) {
48
59
  deploymentMode: base.deploymentMode,
49
60
  summarization: {
50
61
  ...base.summarization,
51
- ...(provider && { provider: provider }),
62
+ ...(provider && VALID_LLM_PROTOCOLS.has(provider) && { provider: provider }),
63
+ ...(providerName && { providerName }),
52
64
  ...(model && { model }),
53
65
  ...(apiKey && { apiKey }),
54
66
  ...(baseUrl && { baseUrl }),
@@ -56,7 +68,7 @@ export function applyRequestHeaders(base, req) {
56
68
  },
57
69
  embedding: {
58
70
  ...base.embedding,
59
- ...(embedProvider && { provider: embedProvider }),
71
+ ...(embedProvider && VALID_EMBED_PROTOCOLS.has(embedProvider) && { provider: embedProvider }),
60
72
  ...(embedModel && { model: embedModel }),
61
73
  ...(embedKey && { apiKey: embedKey }),
62
74
  ...(embedBaseUrl && { baseUrl: embedBaseUrl }),
@@ -1,13 +1,35 @@
1
- export type LLMProvider = "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" | "managed";
2
- export type EmbeddingProvider = "openai" | "anthropic" | "openrouter" | "gemini" | "ollama" | "managed";
1
+ export type LLMProvider = "openai" | "anthropic";
2
+ export type EmbeddingProvider = "openai" | "anthropic" | "openrouter" | "gemini" | "ollama";
3
3
  export type DeploymentMode = "local" | "cloud";
4
4
  export interface SummarizationConfig {
5
5
  provider: LLMProvider;
6
+ providerName?: string;
6
7
  model: string;
7
8
  apiKey?: string;
8
9
  baseUrl?: string;
9
10
  batchSize: number;
10
11
  }
12
+ /** A single provider entry stored in the multi-provider map on disk. */
13
+ export interface ProviderConfigEntry {
14
+ provider: LLMProvider;
15
+ providerName: string;
16
+ model: string;
17
+ apiKey?: string;
18
+ baseUrl?: string;
19
+ batchSize: number;
20
+ }
21
+ /** Storage shape for `summarization` inside config.json (v2 multi-provider). */
22
+ export interface MultiProviderStorage {
23
+ active: string;
24
+ providers: Record<string, ProviderConfigEntry>;
25
+ }
26
+ /** Derive the composite key from a protocol + providerName pair. */
27
+ export declare function makeProviderKey(protocol: string, providerName: string): string;
28
+ /** Parse a composite key back into its parts. */
29
+ export declare function parseProviderKey(key: string): {
30
+ protocol: string;
31
+ providerName: string;
32
+ };
11
33
  export interface EmbeddingConfig {
12
34
  provider: EmbeddingProvider;
13
35
  model: string;
@@ -30,6 +52,7 @@ export declare const OLLAMA_DEFAULTS: DevLensConfig;
30
52
  export declare const ANTHROPIC_DEFAULTS: DevLensConfig;
31
53
  export declare const CONFIG_HEADERS: {
32
54
  readonly PROVIDER: "x-llm-provider";
55
+ readonly PROVIDER_NAME: "x-llm-provider-name";
33
56
  readonly MODEL: "x-llm-model";
34
57
  readonly API_KEY: "x-llm-key";
35
58
  readonly BASE_URL: "x-llm-base-url";
@@ -1,18 +1,30 @@
1
+ /** Derive the composite key from a protocol + providerName pair. */
2
+ export function makeProviderKey(protocol, providerName) {
3
+ return `${protocol}:${providerName}`;
4
+ }
5
+ /** Parse a composite key back into its parts. */
6
+ export function parseProviderKey(key) {
7
+ const idx = key.indexOf(":");
8
+ if (idx === -1)
9
+ throw new Error(`Invalid provider key: "${key}"`);
10
+ return { protocol: key.slice(0, idx), providerName: key.slice(idx + 1) };
11
+ }
1
12
  // Note: apiKey is intentionally absent from all defaults.
2
13
  // If a user reaches the defaults with no key configured anywhere,
3
14
  // the system fails clearly at the LLM call — never silently sends an empty key.
4
15
  export const OLLAMA_DEFAULTS = {
5
16
  deploymentMode: "local",
6
17
  summarization: {
7
- provider: "ollama",
18
+ provider: "openai",
19
+ providerName: "ollama",
8
20
  model: "qwen2.5-coder:3b", // code-aware, 3B params, runs on ~2GB RAM
9
- baseUrl: "http://localhost:11434",
21
+ baseUrl: "http://localhost:11434/v1",
10
22
  batchSize: 50,
11
23
  },
12
24
  embedding: {
13
25
  provider: "ollama",
14
26
  model: "nomic-embed-text", // best local embedding model, 768 dims
15
- baseUrl: "http://localhost:11434",
27
+ baseUrl: "http://localhost:11434/v1",
16
28
  },
17
29
  // neo4j absent — file-only mode is the safe default
18
30
  };
@@ -20,7 +32,9 @@ export const ANTHROPIC_DEFAULTS = {
20
32
  deploymentMode: "local",
21
33
  summarization: {
22
34
  provider: "anthropic",
35
+ providerName: "anthropic",
23
36
  model: "claude-haiku-4-5", // fastest Claude, cheapest, good code understanding
37
+ baseUrl: "https://api.anthropic.com",
24
38
  batchSize: 50,
25
39
  // apiKey intentionally absent — user must set in config.json
26
40
  },
@@ -31,7 +45,7 @@ export const ANTHROPIC_DEFAULTS = {
31
45
  },
32
46
  // neo4j absent
33
47
  };
34
- // ─── Request Header Names ─────────────────────────────────────────────────────
48
+ // Request Header Names
35
49
  //
36
50
  // Exact header names the cloud backend sends to this Bun backend.
37
51
  // Defined as constants so they are never mistyped across files.
@@ -40,7 +54,8 @@ export const ANTHROPIC_DEFAULTS = {
40
54
  // Headers marked "NEVER LOG" must never appear in any log output.
41
55
  export const CONFIG_HEADERS = {
42
56
  // LLM provider for summarization
43
- PROVIDER: "x-llm-provider", // e.g. "anthropic"
57
+ PROVIDER: "x-llm-provider", // e.g. "anthropic" — wire protocol
58
+ PROVIDER_NAME: "x-llm-provider-name", // e.g. "deepseek" — brand identity
44
59
  MODEL: "x-llm-model", // e.g. "claude-haiku-4-5"
45
60
  API_KEY: "x-llm-key",
46
61
  BASE_URL: "x-llm-base-url", // for Ollama: "http://localhost:11434"
@@ -54,7 +69,7 @@ export const CONFIG_HEADERS = {
54
69
  NEO4J_PASSWORD: "x-neo4j-password",
55
70
  NEO4J_STORECODE: "false",
56
71
  };
57
- // ─── Sensitive Headers Set ────────────────────────────────────────────────────
72
+ // Sensitive Headers Set
58
73
  //
59
74
  // Used by sanitizeHeaders() below.
60
75
  // Add any new secret header here the moment it is added to CONFIG_HEADERS.
@@ -63,7 +78,7 @@ const SENSITIVE_HEADERS = new Set([
63
78
  CONFIG_HEADERS.EMBED_KEY,
64
79
  CONFIG_HEADERS.NEO4J_PASSWORD,
65
80
  ]);
66
- // ─── sanitizeHeaders ──────────────────────────────────────────────────────────
81
+ // sanitizeHeaders
67
82
  //
68
83
  // Call this before logging ANY request headers anywhere in the codebase.
69
84
  // Replaces secret values with "[REDACTED]" so API keys never appear in logs.
@@ -1,4 +1,4 @@
1
- import type { DevLensConfig } from "./types.js";
1
+ import type { DevLensConfig, ProviderConfigEntry } from "./types.js";
2
2
  type DeepPartial<T> = {
3
3
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
4
4
  };
@@ -7,10 +7,15 @@ export interface SafeConfig {
7
7
  deploymentMode: DevLensConfig["deploymentMode"];
8
8
  summarization: {
9
9
  provider: string;
10
+ providerName?: string;
10
11
  model: string;
11
12
  baseUrl?: string;
12
13
  batchSize: number;
13
- apiKeyHint?: string;
14
+ apiKey?: string;
15
+ };
16
+ allProviders?: {
17
+ active: string;
18
+ providers: ProviderConfigEntry[];
14
19
  };
15
20
  embedding: {
16
21
  provider: string;
@@ -24,6 +29,7 @@ export interface SafeConfig {
24
29
  storeRawCode: boolean;
25
30
  };
26
31
  }
32
+ export declare function atomicWrite(filePath: string, content: string): void;
27
33
  export declare function writeConfig(partial: PartialConfig): void;
28
34
  export declare function maskConfig(config: DevLensConfig): SafeConfig;
29
35
  export {};