@mandujs/core 0.39.2 → 0.40.0

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.
@@ -1,8 +1,296 @@
1
1
  /**
2
- * Brain v0.1 - LLM Adapters
2
+ * Brain v0.2 - LLM Adapters (resolver + factory).
3
3
  *
4
- * Export all adapter implementations and utilities.
4
+ * `createBrainAdapter(config)` picks the right adapter based on
5
+ * declarative config + runtime signals. Resolution order when
6
+ * `adapter: "auto"` (or `brain` config omitted entirely):
7
+ *
8
+ * 1. openai-oauth — token present in the keychain
9
+ * 2. anthropic-oauth — token present in the keychain
10
+ * 3. ollama — daemon reachable at localhost:11434
11
+ * 4. template — always works (returns NoopAdapter; Brain
12
+ * gracefully falls back to template analysis)
13
+ *
14
+ * `telemetryOptOut: true` disables every cloud tier — the resolver
15
+ * skips straight to ollama/template regardless of stored tokens.
16
+ *
17
+ * Explicit `adapter: "openai"` / `"anthropic"` / `"ollama"` /
18
+ * `"template"` pins the choice; the resolver still degrades to the
19
+ * NoopAdapter when the chosen provider is unreachable, so the caller
20
+ * never explodes on a missing dependency.
5
21
  */
6
22
 
7
23
  export * from "./base";
8
24
  export * from "./ollama";
25
+ export * from "./openai-oauth";
26
+ export * from "./anthropic-oauth";
27
+ export * from "./oauth-flow";
28
+
29
+ import { type LLMAdapter, NoopAdapter } from "./base";
30
+ import { OllamaAdapter, createOllamaAdapter } from "./ollama";
31
+ import {
32
+ OpenAIOAuthAdapter,
33
+ createOpenAIOAuthAdapter,
34
+ type OpenAIOAuthAdapterOptions,
35
+ } from "./openai-oauth";
36
+ import {
37
+ AnthropicOAuthAdapter,
38
+ createAnthropicOAuthAdapter,
39
+ type AnthropicOAuthAdapterOptions,
40
+ } from "./anthropic-oauth";
41
+ import {
42
+ getCredentialStore,
43
+ type CredentialStore,
44
+ type StoredToken,
45
+ } from "../credentials";
46
+
47
+ /**
48
+ * Normalised Brain config shape consumed by the resolver. Mirrors
49
+ * `ManduConfig.brain` but with every field required-or-explicitly
50
+ * defaulted so downstream code does not repeat the same null checks.
51
+ */
52
+ export interface BrainAdapterConfig {
53
+ adapter: "auto" | "openai" | "anthropic" | "ollama" | "template";
54
+ openai?: { model?: string };
55
+ anthropic?: { model?: string };
56
+ ollama?: { model?: string; baseUrl?: string };
57
+ /**
58
+ * When true, cloud adapters are disabled entirely. The resolver
59
+ * falls to ollama/template regardless of stored tokens.
60
+ */
61
+ telemetryOptOut?: boolean;
62
+ /**
63
+ * Project root — required for consent scoping + redaction audit log.
64
+ * Defaults to `process.cwd()` when omitted.
65
+ */
66
+ projectRoot?: string;
67
+ /** Credential store override — tests inject an in-memory one. */
68
+ credentialStore?: CredentialStore;
69
+ /** Override OpenAI-specific adapter options (tests only). */
70
+ openaiOptions?: OpenAIOAuthAdapterOptions;
71
+ /** Override Anthropic-specific adapter options (tests only). */
72
+ anthropicOptions?: AnthropicOAuthAdapterOptions;
73
+ /**
74
+ * Override the ollama-reachability probe. When omitted we call
75
+ * `OllamaAdapter.isServerRunning()` which is the production path.
76
+ */
77
+ probeOllama?: (adapter: OllamaAdapter) => Promise<boolean>;
78
+ /**
79
+ * Override the keychain probe used by the auto-resolver. Returns
80
+ * the stored token or null. Tests inject a deterministic stub; the
81
+ * default consults `credentialStore.load(provider)`.
82
+ */
83
+ probeToken?: (provider: "openai" | "anthropic") => Promise<StoredToken | null>;
84
+ }
85
+
86
+ /**
87
+ * Result of the resolver — returned so callers can log which tier
88
+ * won (surfaced in `mandu brain status`).
89
+ */
90
+ export interface BrainAdapterResolution {
91
+ adapter: LLMAdapter;
92
+ /** Which tier the resolver picked. */
93
+ resolved: "openai" | "anthropic" | "ollama" | "template";
94
+ /** Which tier the caller asked for (config value). */
95
+ requested: BrainAdapterConfig["adapter"];
96
+ /** Human-readable reason — useful for `mandu brain status`. */
97
+ reason: string;
98
+ }
99
+
100
+ /**
101
+ * Resolve + construct a Brain adapter.
102
+ *
103
+ * Callers typically use the convenience re-export
104
+ * `createBrainAdapter(config)`. The full `resolveBrainAdapter()`
105
+ * surface returns the metadata record so CLI status commands can
106
+ * explain which tier won.
107
+ */
108
+ export async function resolveBrainAdapter(
109
+ config: Partial<BrainAdapterConfig> = {},
110
+ ): Promise<BrainAdapterResolution> {
111
+ const requested = config.adapter ?? "auto";
112
+ const telemetryOptOut = config.telemetryOptOut ?? false;
113
+
114
+ const store = config.credentialStore ?? getCredentialStore();
115
+ const projectRoot = config.projectRoot ?? process.cwd();
116
+
117
+ const probeToken =
118
+ config.probeToken ?? ((provider) => store.load(provider));
119
+
120
+ const probeOllama =
121
+ config.probeOllama ??
122
+ (async (ollama: OllamaAdapter) => ollama.isServerRunning());
123
+
124
+ // Explicit template — skip every other check.
125
+ if (requested === "template") {
126
+ return {
127
+ adapter: new NoopAdapter(),
128
+ resolved: "template",
129
+ requested,
130
+ reason: "Explicit adapter: 'template' in config",
131
+ };
132
+ }
133
+
134
+ // Explicit openai — only honored when telemetry is allowed AND a
135
+ // token exists. Falls back to template otherwise so Core does not
136
+ // explode.
137
+ if (requested === "openai") {
138
+ if (telemetryOptOut) {
139
+ return {
140
+ adapter: new NoopAdapter(),
141
+ resolved: "template",
142
+ requested,
143
+ reason:
144
+ "adapter: 'openai' requested but telemetryOptOut=true — forcing template",
145
+ };
146
+ }
147
+ const token = await probeToken("openai");
148
+ if (!token) {
149
+ return {
150
+ adapter: new NoopAdapter(),
151
+ resolved: "template",
152
+ requested,
153
+ reason:
154
+ "adapter: 'openai' requested but no token in keychain — run `mandu brain login --provider=openai`",
155
+ };
156
+ }
157
+ return {
158
+ adapter: createOpenAIOAuthAdapter({
159
+ ...(config.openaiOptions ?? {}),
160
+ model: config.openai?.model ?? config.openaiOptions?.model,
161
+ credentialStore: store,
162
+ projectRoot,
163
+ }),
164
+ resolved: "openai",
165
+ requested,
166
+ reason: "Explicit adapter: 'openai' + token present",
167
+ };
168
+ }
169
+
170
+ if (requested === "anthropic") {
171
+ if (telemetryOptOut) {
172
+ return {
173
+ adapter: new NoopAdapter(),
174
+ resolved: "template",
175
+ requested,
176
+ reason:
177
+ "adapter: 'anthropic' requested but telemetryOptOut=true — forcing template",
178
+ };
179
+ }
180
+ const token = await probeToken("anthropic");
181
+ if (!token) {
182
+ return {
183
+ adapter: new NoopAdapter(),
184
+ resolved: "template",
185
+ requested,
186
+ reason:
187
+ "adapter: 'anthropic' requested but no token in keychain — run `mandu brain login --provider=anthropic`",
188
+ };
189
+ }
190
+ return {
191
+ adapter: createAnthropicOAuthAdapter({
192
+ ...(config.anthropicOptions ?? {}),
193
+ model: config.anthropic?.model ?? config.anthropicOptions?.model,
194
+ credentialStore: store,
195
+ projectRoot,
196
+ }),
197
+ resolved: "anthropic",
198
+ requested,
199
+ reason: "Explicit adapter: 'anthropic' + token present",
200
+ };
201
+ }
202
+
203
+ if (requested === "ollama") {
204
+ const ollama = createOllamaAdapter({
205
+ model: config.ollama?.model,
206
+ baseUrl: config.ollama?.baseUrl,
207
+ });
208
+ return {
209
+ adapter: ollama,
210
+ resolved: "ollama",
211
+ requested,
212
+ reason: "Explicit adapter: 'ollama'",
213
+ };
214
+ }
215
+
216
+ // Auto — try cloud providers first (when allowed), then ollama,
217
+ // then template.
218
+ if (!telemetryOptOut) {
219
+ const openaiToken = await probeToken("openai");
220
+ if (openaiToken) {
221
+ return {
222
+ adapter: createOpenAIOAuthAdapter({
223
+ ...(config.openaiOptions ?? {}),
224
+ model: config.openai?.model ?? config.openaiOptions?.model,
225
+ credentialStore: store,
226
+ projectRoot,
227
+ }),
228
+ resolved: "openai",
229
+ requested,
230
+ reason: "auto: OpenAI token found in keychain",
231
+ };
232
+ }
233
+ const anthropicToken = await probeToken("anthropic");
234
+ if (anthropicToken) {
235
+ return {
236
+ adapter: createAnthropicOAuthAdapter({
237
+ ...(config.anthropicOptions ?? {}),
238
+ model: config.anthropic?.model ?? config.anthropicOptions?.model,
239
+ credentialStore: store,
240
+ projectRoot,
241
+ }),
242
+ resolved: "anthropic",
243
+ requested,
244
+ reason: "auto: Anthropic token found in keychain",
245
+ };
246
+ }
247
+ }
248
+
249
+ const ollama = createOllamaAdapter({
250
+ model: config.ollama?.model,
251
+ baseUrl: config.ollama?.baseUrl,
252
+ });
253
+ const ollamaAlive = await probeOllama(ollama).catch(() => false);
254
+ if (ollamaAlive) {
255
+ return {
256
+ adapter: ollama,
257
+ resolved: "ollama",
258
+ requested,
259
+ reason: telemetryOptOut
260
+ ? "auto: telemetryOptOut=true, ollama daemon reachable"
261
+ : "auto: no cloud token, ollama daemon reachable",
262
+ };
263
+ }
264
+
265
+ return {
266
+ adapter: new NoopAdapter(),
267
+ resolved: "template",
268
+ requested,
269
+ reason: telemetryOptOut
270
+ ? "auto: telemetryOptOut=true and no local LLM — using template"
271
+ : "auto: no cloud token, no ollama daemon — using template",
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Convenience factory — returns just the adapter. Use
277
+ * `resolveBrainAdapter()` when you also need the resolution metadata
278
+ * (e.g. for `mandu brain status`).
279
+ */
280
+ export async function createBrainAdapter(
281
+ config: Partial<BrainAdapterConfig> = {},
282
+ ): Promise<LLMAdapter> {
283
+ const res = await resolveBrainAdapter(config);
284
+ return res.adapter;
285
+ }
286
+
287
+ /**
288
+ * Runtime guard — is the adapter a cloud connector? Used by CLI
289
+ * status to flag "may transmit data" lines.
290
+ */
291
+ export function isCloudAdapter(adapter: LLMAdapter): boolean {
292
+ return (
293
+ adapter instanceof OpenAIOAuthAdapter ||
294
+ adapter instanceof AnthropicOAuthAdapter
295
+ );
296
+ }