@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.
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Brain — Anthropic OAuth adapter (Issue #235).
3
+ *
4
+ * Mirrors the OpenAI adapter but targets Anthropic's Messages API
5
+ * (`POST /v1/messages`). Mandu does not own Anthropic API keys — the
6
+ * user's OAuth credentials live in the OS keychain and are forwarded
7
+ * on each request.
8
+ *
9
+ * Why the default model is `claude-haiku-4-5-20251001`: brain-doctor
10
+ * triage prompts are short and latency-sensitive. Haiku is the cheapest
11
+ * model in the family that still produces syntactically valid code
12
+ * suggestions in our exploratory tests. Users who prefer Sonnet /
13
+ * Opus override via `ManduConfig.brain.anthropic.model`.
14
+ */
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { BaseLLMAdapter } from "./base";
20
+ import type {
21
+ AdapterConfig,
22
+ AdapterStatus,
23
+ ChatMessage,
24
+ CompletionOptions,
25
+ CompletionResult,
26
+ } from "../types";
27
+ import {
28
+ CredentialStore,
29
+ getCredentialStore,
30
+ type StoredToken,
31
+ } from "../credentials";
32
+ import {
33
+ ensureConsent,
34
+ type ConsentPromptDeps,
35
+ } from "../consent";
36
+ import { redactSecrets } from "../redactor";
37
+ import {
38
+ refreshAccessToken,
39
+ runAuthorizationCodeFlow,
40
+ type HttpClient,
41
+ type OAuthEndpoints,
42
+ } from "./oauth-flow";
43
+
44
+ /* -------------------------------------------------------------------- */
45
+ /* Defaults */
46
+ /* -------------------------------------------------------------------- */
47
+
48
+ export const ANTHROPIC_OAUTH_ENDPOINTS: OAuthEndpoints = {
49
+ authorizationUrl: "https://console.anthropic.com/oauth/authorize",
50
+ tokenUrl: "https://console.anthropic.com/oauth/token",
51
+ };
52
+
53
+ /** Public client id registered by Mandu — PKCE secures the exchange. */
54
+ export const ANTHROPIC_OAUTH_CLIENT_ID = "mandu-brain-cli";
55
+ export const ANTHROPIC_OAUTH_SCOPE = "messages:write";
56
+ export const ANTHROPIC_API_BASE = "https://api.anthropic.com/v1";
57
+ /** Must be bumped whenever Anthropic ships a breaking Messages API version. */
58
+ export const ANTHROPIC_API_VERSION = "2023-06-01";
59
+
60
+ /**
61
+ * Default model — Haiku 4.5. Fast, cheap, and good enough for the
62
+ * triage prompts brain-doctor issues.
63
+ */
64
+ export const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
65
+
66
+ export const DEFAULT_ANTHROPIC_CONFIG: AdapterConfig = {
67
+ baseUrl: ANTHROPIC_API_BASE,
68
+ model: ANTHROPIC_DEFAULT_MODEL,
69
+ timeout: 60_000,
70
+ };
71
+
72
+ /* -------------------------------------------------------------------- */
73
+ /* Options */
74
+ /* -------------------------------------------------------------------- */
75
+
76
+ export interface AnthropicOAuthAdapterOptions extends Partial<AdapterConfig> {
77
+ httpClient?: HttpClient;
78
+ endpoints?: OAuthEndpoints;
79
+ clientId?: string;
80
+ scope?: string;
81
+ credentialStore?: CredentialStore;
82
+ projectRoot?: string;
83
+ skipConsent?: boolean;
84
+ consentDeps?: ConsentPromptDeps;
85
+ /** Anthropic API version header — override only for enterprise proxies. */
86
+ apiVersion?: string;
87
+ strict?: boolean;
88
+ }
89
+
90
+ /* -------------------------------------------------------------------- */
91
+ /* Adapter */
92
+ /* -------------------------------------------------------------------- */
93
+
94
+ export class AnthropicOAuthAdapter extends BaseLLMAdapter {
95
+ readonly name = "anthropic-oauth";
96
+ private httpClient: HttpClient;
97
+ private endpoints: OAuthEndpoints;
98
+ private clientId: string;
99
+ private scope: string;
100
+ private credentialStore: CredentialStore;
101
+ private projectRoot: string;
102
+ private skipConsent: boolean;
103
+ private consentDeps?: ConsentPromptDeps;
104
+ private apiVersion: string;
105
+ private strict: boolean;
106
+ private refreshInFlight: Promise<StoredToken | null> | null = null;
107
+
108
+ constructor(options: AnthropicOAuthAdapterOptions = {}) {
109
+ super({
110
+ ...DEFAULT_ANTHROPIC_CONFIG,
111
+ ...options,
112
+ });
113
+ this.httpClient =
114
+ options.httpClient ?? globalThis.fetch.bind(globalThis);
115
+ this.endpoints = options.endpoints ?? ANTHROPIC_OAUTH_ENDPOINTS;
116
+ this.clientId = options.clientId ?? ANTHROPIC_OAUTH_CLIENT_ID;
117
+ this.scope = options.scope ?? ANTHROPIC_OAUTH_SCOPE;
118
+ this.credentialStore = options.credentialStore ?? getCredentialStore();
119
+ this.projectRoot = options.projectRoot ?? process.cwd();
120
+ this.skipConsent = options.skipConsent ?? false;
121
+ this.consentDeps = options.consentDeps;
122
+ this.apiVersion = options.apiVersion ?? ANTHROPIC_API_VERSION;
123
+ this.strict = options.strict ?? false;
124
+ }
125
+
126
+ async checkStatus(): Promise<AdapterStatus> {
127
+ const token = await this.credentialStore.load("anthropic");
128
+ if (!token) {
129
+ return {
130
+ available: false,
131
+ model: null,
132
+ error:
133
+ "No Anthropic OAuth token stored. Run `mandu brain login --provider=anthropic` first.",
134
+ };
135
+ }
136
+ return {
137
+ available: true,
138
+ model: this.config.model,
139
+ };
140
+ }
141
+
142
+ async login(
143
+ opts: {
144
+ onAuthUrl?: (url: string) => void;
145
+ openBrowser?: (url: string) => Promise<void> | void;
146
+ timeoutMs?: number;
147
+ } = {},
148
+ ): Promise<StoredToken> {
149
+ const tokenResponse = await runAuthorizationCodeFlow({
150
+ endpoints: this.endpoints,
151
+ client: { clientId: this.clientId, scope: this.scope },
152
+ httpClient: this.httpClient,
153
+ onAuthUrl: opts.onAuthUrl,
154
+ openBrowser: opts.openBrowser,
155
+ timeoutMs: opts.timeoutMs,
156
+ });
157
+
158
+ const stored: StoredToken = {
159
+ access_token: tokenResponse.access_token,
160
+ refresh_token: tokenResponse.refresh_token,
161
+ expires_at:
162
+ typeof tokenResponse.expires_in === "number"
163
+ ? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
164
+ : undefined,
165
+ scope: tokenResponse.scope ?? this.scope,
166
+ default_model: this.config.model,
167
+ provider: "anthropic",
168
+ last_used_at: new Date().toISOString(),
169
+ };
170
+ await this.credentialStore.save("anthropic", stored);
171
+ return stored;
172
+ }
173
+
174
+ async logout(): Promise<void> {
175
+ await this.credentialStore.delete("anthropic");
176
+ }
177
+
178
+ async complete(
179
+ messages: ChatMessage[],
180
+ options: CompletionOptions = {},
181
+ ): Promise<CompletionResult> {
182
+ const token = await this.credentialStore.load("anthropic");
183
+ if (!token) {
184
+ if (this.strict) {
185
+ throw new Error(
186
+ "AnthropicOAuthAdapter.complete() called without a stored token",
187
+ );
188
+ }
189
+ return emptyCompletion();
190
+ }
191
+
192
+ if (!this.skipConsent) {
193
+ const ok = await ensureConsent(
194
+ {
195
+ projectRoot: this.projectRoot,
196
+ provider: "anthropic",
197
+ model: this.config.model,
198
+ payloadDescription: describeChatPayload(messages),
199
+ },
200
+ this.consentDeps,
201
+ );
202
+ if (!ok) return emptyCompletion();
203
+ }
204
+
205
+ const redactedMessages: ChatMessage[] = [];
206
+ const audit: string[] = [];
207
+ for (const m of messages) {
208
+ const { redacted, hits } = redactSecrets(m.content);
209
+ redactedMessages.push({ role: m.role, content: redacted });
210
+ for (const hit of hits) {
211
+ audit.push(
212
+ JSON.stringify({
213
+ ts: new Date().toISOString(),
214
+ provider: "anthropic",
215
+ model: this.config.model,
216
+ role: m.role,
217
+ kind: hit.kind,
218
+ sample: hit.sample,
219
+ }),
220
+ );
221
+ }
222
+ }
223
+ if (audit.length > 0) {
224
+ await appendRedactionLog(this.projectRoot, audit);
225
+ }
226
+
227
+ let attemptToken = token.access_token;
228
+ let result = await this.callMessagesApi(
229
+ attemptToken,
230
+ redactedMessages,
231
+ options,
232
+ );
233
+ if (result.status === 401 && token.refresh_token) {
234
+ const refreshed = await this.trySilentRefresh(token);
235
+ if (refreshed) {
236
+ attemptToken = refreshed.access_token;
237
+ result = await this.callMessagesApi(
238
+ attemptToken,
239
+ redactedMessages,
240
+ options,
241
+ );
242
+ }
243
+ }
244
+ if (result.status === 401) {
245
+ await this.credentialStore.delete("anthropic");
246
+ return emptyCompletion();
247
+ }
248
+ if (!result.ok) {
249
+ throw new Error(
250
+ `Anthropic request failed (${result.status}): ${result.bodySnippet}`,
251
+ );
252
+ }
253
+ await this.credentialStore.touch("anthropic");
254
+ return result.completion;
255
+ }
256
+
257
+ private async trySilentRefresh(
258
+ existing: StoredToken,
259
+ ): Promise<StoredToken | null> {
260
+ if (!existing.refresh_token) return null;
261
+ if (this.refreshInFlight) return this.refreshInFlight;
262
+ this.refreshInFlight = (async () => {
263
+ try {
264
+ const refreshed = await refreshAccessToken({
265
+ endpoints: this.endpoints,
266
+ clientId: this.clientId,
267
+ refreshToken: existing.refresh_token!,
268
+ httpClient: this.httpClient,
269
+ scope: existing.scope ?? this.scope,
270
+ });
271
+ const stored: StoredToken = {
272
+ access_token: refreshed.access_token,
273
+ refresh_token: refreshed.refresh_token ?? existing.refresh_token,
274
+ expires_at:
275
+ typeof refreshed.expires_in === "number"
276
+ ? Math.floor(Date.now() / 1000) + refreshed.expires_in
277
+ : undefined,
278
+ scope: refreshed.scope ?? existing.scope,
279
+ default_model: existing.default_model,
280
+ provider: "anthropic",
281
+ last_used_at: new Date().toISOString(),
282
+ };
283
+ await this.credentialStore.save("anthropic", stored);
284
+ return stored;
285
+ } catch {
286
+ return null;
287
+ } finally {
288
+ this.refreshInFlight = null;
289
+ }
290
+ })();
291
+ return this.refreshInFlight;
292
+ }
293
+
294
+ /**
295
+ * Transform our shared ChatMessage shape into Anthropic's Messages
296
+ * API request body. Anthropic separates the system message from
297
+ * the conversation turns; we concatenate any leading system
298
+ * messages into one `system` string.
299
+ */
300
+ private async callMessagesApi(
301
+ accessToken: string,
302
+ messages: ChatMessage[],
303
+ options: CompletionOptions,
304
+ ): Promise<
305
+ | { ok: true; status: number; completion: CompletionResult }
306
+ | { ok: false; status: number; bodySnippet: string; completion: CompletionResult }
307
+ > {
308
+ const systemParts: string[] = [];
309
+ const conversation: Array<{
310
+ role: "user" | "assistant";
311
+ content: string;
312
+ }> = [];
313
+ for (const m of messages) {
314
+ if (m.role === "system") {
315
+ systemParts.push(m.content);
316
+ } else if (m.role === "user" || m.role === "assistant") {
317
+ conversation.push({ role: m.role, content: m.content });
318
+ }
319
+ }
320
+
321
+ const body: Record<string, unknown> = {
322
+ model: this.config.model,
323
+ messages: conversation,
324
+ max_tokens: options.maxTokens ?? 2048,
325
+ temperature: options.temperature ?? 0.2,
326
+ };
327
+ if (systemParts.length > 0) body.system = systemParts.join("\n\n");
328
+ if (options.stop && options.stop.length > 0) {
329
+ body.stop_sequences = options.stop;
330
+ }
331
+
332
+ const res = await this.httpClient(`${this.baseUrl}/messages`, {
333
+ method: "POST",
334
+ headers: {
335
+ authorization: `Bearer ${accessToken}`,
336
+ "content-type": "application/json",
337
+ accept: "application/json",
338
+ "anthropic-version": this.apiVersion,
339
+ },
340
+ body: JSON.stringify(body),
341
+ });
342
+
343
+ if (!res.ok) {
344
+ const txt = await res.text().catch(() => "");
345
+ return {
346
+ ok: false,
347
+ status: res.status,
348
+ bodySnippet: txt.slice(0, 256),
349
+ completion: emptyCompletion(),
350
+ };
351
+ }
352
+
353
+ const json = (await res.json()) as {
354
+ content?: Array<{ type?: string; text?: string }>;
355
+ usage?: { input_tokens?: number; output_tokens?: number };
356
+ };
357
+ const text =
358
+ json.content
359
+ ?.filter((c) => c.type === "text")
360
+ .map((c) => c.text ?? "")
361
+ .join("") ?? "";
362
+ const input = json.usage?.input_tokens ?? 0;
363
+ const output = json.usage?.output_tokens ?? 0;
364
+ return {
365
+ ok: true,
366
+ status: res.status,
367
+ completion: {
368
+ content: text,
369
+ usage: {
370
+ promptTokens: input,
371
+ completionTokens: output,
372
+ totalTokens: input + output,
373
+ },
374
+ },
375
+ };
376
+ }
377
+ }
378
+
379
+ /* -------------------------------------------------------------------- */
380
+ /* Helpers */
381
+ /* -------------------------------------------------------------------- */
382
+
383
+ function emptyCompletion(): CompletionResult {
384
+ return {
385
+ content: "",
386
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
387
+ };
388
+ }
389
+
390
+ function describeChatPayload(messages: ChatMessage[]): string {
391
+ const totalChars = messages.reduce((a, m) => a + m.content.length, 0);
392
+ const roleCounts = new Map<string, number>();
393
+ for (const m of messages) {
394
+ roleCounts.set(m.role, (roleCounts.get(m.role) ?? 0) + 1);
395
+ }
396
+ const roleSummary = [...roleCounts.entries()]
397
+ .map(([r, n]) => `${n} ${r}`)
398
+ .join(", ");
399
+ return `${messages.length} messages (${roleSummary}), ~${totalChars} chars`;
400
+ }
401
+
402
+ async function appendRedactionLog(
403
+ projectRoot: string,
404
+ entries: string[],
405
+ ): Promise<void> {
406
+ try {
407
+ const dir = path.join(projectRoot, ".mandu");
408
+ await fs.mkdir(dir, { recursive: true });
409
+ const file = path.join(dir, "brain-redactions.jsonl");
410
+ await fs.appendFile(file, `${entries.join("\n")}\n`, { mode: 0o600 });
411
+ } catch {
412
+ /* best-effort */
413
+ }
414
+ }
415
+
416
+ export function createAnthropicOAuthAdapter(
417
+ options: AnthropicOAuthAdapterOptions = {},
418
+ ): AnthropicOAuthAdapter {
419
+ return new AnthropicOAuthAdapter(options);
420
+ }