backend-manager 5.6.3 → 5.7.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CLAUDE.md +4 -3
  3. package/PROGRESS.md +34 -0
  4. package/docs/ai-library.md +62 -11
  5. package/docs/cdp-debugging.md +44 -0
  6. package/docs/cli-output.md +22 -10
  7. package/docs/mcp.md +166 -43
  8. package/docs/test-framework.md +2 -2
  9. package/package.json +1 -1
  10. package/plans/mcp2.md +247 -0
  11. package/src/cli/commands/mcp.js +8 -2
  12. package/src/cli/commands/serve.js +155 -29
  13. package/src/cli/commands/setup-tests/base-test.js +8 -0
  14. package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
  15. package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
  16. package/src/cli/commands/setup-tests/index.js +4 -0
  17. package/src/cli/commands/setup-tests/java-installed.js +26 -0
  18. package/src/cli/commands/setup.js +2 -1
  19. package/src/cli/commands/test.js +13 -0
  20. package/src/cli/index.js +14 -0
  21. package/src/cli/utils/ui.js +27 -5
  22. package/src/manager/index.js +8 -3
  23. package/src/manager/libraries/ai/index.js +45 -1
  24. package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
  25. package/src/manager/libraries/ai/providers/anthropic.js +28 -49
  26. package/src/manager/libraries/ai/providers/claude-code.js +21 -47
  27. package/src/manager/libraries/ai/providers/openai.js +154 -19
  28. package/src/manager/libraries/ai/providers/test.js +242 -0
  29. package/src/manager/libraries/email/data/disposable-domains.json +465 -0
  30. package/src/mcp/client.js +48 -13
  31. package/src/mcp/handler.js +222 -69
  32. package/src/mcp/index.js +48 -18
  33. package/src/mcp/tools.js +150 -0
  34. package/src/mcp/utils.js +108 -0
  35. package/src/test/fixtures/firebase-project/firebase.json +1 -1
  36. package/src/test/test-accounts.js +31 -0
  37. package/test/ai/tools-live.js +170 -0
  38. package/test/email/marketing-lifecycle.js +10 -5
  39. package/test/helpers/ai-test-provider.js +202 -0
  40. package/test/helpers/ai-tools-format.js +350 -0
  41. package/test/mcp/discovery.js +53 -0
  42. package/test/mcp/oauth.js +161 -0
  43. package/test/mcp/protocol.js +268 -0
  44. package/test/mcp/roles.js +168 -0
  45. package/test/mcp/utils.js +245 -0
  46. package/test/routes/marketing/webhook.js +37 -33
  47. package/.claude/settings.local.json +0 -12
@@ -237,6 +237,11 @@ class TestCommand extends BaseCommand {
237
237
  process.env.BACKEND_MANAGER_WEBHOOK_KEY = process.env.BACKEND_MANAGER_WEBHOOK_KEY || cfg.backend_manager?.webhookKey;
238
238
  } catch (_) { /* fixture config unreadable — let the normal key check report it */ }
239
239
 
240
+ // Anonymous HMAC unsubscribe tests sign links with this shared secret; the
241
+ // emulated functions inherit it from this process env (same mechanism as the
242
+ // webhook key above). Test-only value — production sets its own env var.
243
+ process.env.UNSUBSCRIBE_HMAC_KEY = process.env.UNSUBSCRIBE_HMAC_KEY || '_test-unsubscribe-hmac-key';
244
+
240
245
  this.ensureFixtureServiceAccount(fixture);
241
246
  this.linkFixtureDeps(fixture);
242
247
  this.log(chalk.cyan(` Self-test: booting bundled fixture project (${fixture})`));
@@ -437,6 +442,14 @@ class TestCommand extends BaseCommand {
437
442
  * - `test.log` — the test-runner subprocess stdout/stderr (managed here)
438
443
  */
439
444
  async runEmulatorTests(testCommand, functionsDir) {
445
+ try {
446
+ await powertools.execute('java -version', { log: false });
447
+ } catch (e) {
448
+ this.logError(`Java is required to run tests (Firebase emulators depend on it).`);
449
+ this.logError(`Install with: brew install openjdk`);
450
+ process.exit(1);
451
+ }
452
+
440
453
  this.log(chalk.gray(' Starting Firebase emulator...\n'));
441
454
 
442
455
  const emulatorCmd = new EmulatorCommand(this.main);
package/src/cli/index.js CHANGED
@@ -194,6 +194,20 @@ Main.prototype.test = async function(name, fn, fix, args) {
194
194
  return;
195
195
  }
196
196
 
197
+ // A check that returns 'warn' is a non-blocking failure — reported in the
198
+ // summary but does not halt setup. The warning details come from args.details
199
+ // (an array of pre-formatted lines) set by the caller.
200
+ if (passed === 'warn') {
201
+ self.testTotal++;
202
+ printLine(self.testTotal, 'warn', '');
203
+ const details = (args && typeof args.details === 'function') ? args.details() : (args && args.details) || [];
204
+ for (const line of details) {
205
+ ui.status('warn', chalk.yellow(line), { level: 3 });
206
+ }
207
+ if (self.setupSummary) { self.setupSummary.warn(name, details.map(d => chalk.yellow(d))); }
208
+ return;
209
+ }
210
+
197
211
  if (passed) {
198
212
  self.testCount++;
199
213
  self.testTotal++;
@@ -164,6 +164,7 @@ class Summary {
164
164
  constructor() {
165
165
  this.startTime = null;
166
166
  this.passes = 0;
167
+ this.warns = [];
167
168
  this.fails = [];
168
169
  }
169
170
 
@@ -176,6 +177,14 @@ class Summary {
176
177
  this.passes++;
177
178
  }
178
179
 
180
+ /**
181
+ * @param {string} name - Check name.
182
+ * @param {string[]} [details] - Pre-formatted detail lines to show under the warning.
183
+ */
184
+ warn(name, details = []) {
185
+ this.warns.push({ name, details });
186
+ }
187
+
179
188
  /**
180
189
  * @param {string} name - Check name.
181
190
  * @param {string[]} [details] - Pre-formatted detail lines to show under the failure.
@@ -185,7 +194,7 @@ class Summary {
185
194
  }
186
195
 
187
196
  get total() {
188
- return this.passes + this.fails.length;
197
+ return this.passes + this.warns.length + this.fails.length;
189
198
  }
190
199
 
191
200
  /**
@@ -214,6 +223,7 @@ class Summary {
214
223
  */
215
224
  print(opts = {}) {
216
225
  const hasErrors = this.fails.length > 0;
226
+ const hasWarnings = this.warns.length > 0;
217
227
  const headerColor = hasErrors ? chalk.yellow : chalk.green;
218
228
  const icon = hasErrors ? SYMBOLS.warn : SYMBOLS.done;
219
229
  const elapsed = this.startTime ? this._formatDuration(Date.now() - this.startTime) : '0s';
@@ -226,10 +236,22 @@ class Summary {
226
236
  blank();
227
237
  field('Checks', String(this.total), { pad: 11 });
228
238
  field('Duration', elapsed, { pad: 11 });
229
- const failText = hasErrors
230
- ? chalk.red(`${this.fails.length} failed`)
231
- : chalk.dim('0 failed');
232
- field('Results', `${chalk.green(`${this.passes} passed`)}${chalk.dim(', ')}${failText}`, { pad: 11 });
239
+ const parts = [chalk.green(`${this.passes} passed`)];
240
+ if (hasWarnings) {
241
+ parts.push(chalk.yellow(`${this.warns.length} warned`));
242
+ }
243
+ parts.push(hasErrors ? chalk.red(`${this.fails.length} failed`) : chalk.dim('0 failed'));
244
+ field('Results', parts.join(chalk.dim(', ')), { pad: 11 });
245
+
246
+ if (hasWarnings) {
247
+ blank();
248
+ for (const { name, details } of this.warns) {
249
+ console.log(`${indent(1)}${chalk.yellow(SYMBOLS.warn)} ${chalk.bold(name)}`);
250
+ for (const line of details) {
251
+ console.log(`${indent(3)}${line}`);
252
+ }
253
+ }
254
+ }
233
255
 
234
256
  if (hasErrors) {
235
257
  blank();
@@ -254,9 +254,11 @@ Manager.prototype.init = function (exporter, options) {
254
254
  // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
255
255
  // the parent is a real remote server with no localhost equivalent.
256
256
  const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
257
- return isDev
258
- ? 'http://localhost:5002'
259
- : `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
257
+ if (isDev) {
258
+ const httpsPort = process.env.BEM_HTTPS_PORT;
259
+ return httpsPort ? `https://localhost:${httpsPort}` : 'http://localhost:5002';
260
+ }
261
+ return `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
260
262
  };
261
263
 
262
264
  self.getWebsiteUrl = function(env) {
@@ -1352,6 +1354,9 @@ function resolveMcpRoutePath(routePath) {
1352
1354
  if (routePath === 'token') {
1353
1355
  return 'mcp/token';
1354
1356
  }
1357
+ if (routePath === 'register') {
1358
+ return 'mcp/register';
1359
+ }
1355
1360
 
1356
1361
  return null;
1357
1362
  }
@@ -13,6 +13,7 @@
13
13
  const OpenAI = require('./providers/openai.js');
14
14
  const Anthropic = require('./providers/anthropic.js');
15
15
  const ClaudeCode = require('./providers/claude-code.js');
16
+ const TestProvider = require('./providers/test.js');
16
17
 
17
18
  const DEFAULT_PROVIDER = 'openai';
18
19
 
@@ -153,6 +154,25 @@ AI.prototype._getProvider = function (provider, apiKey) {
153
154
  */
154
155
  function normalizeOptions(opts) {
155
156
  const out = { ...opts };
157
+ const rules = SYSTEM_PROMPT_INJECTIONS.join('\n');
158
+
159
+ // Structured conversations (tool-call turns, tool results, raw content
160
+ // blocks) must NOT be flattened into prompt/message — the provider consumes
161
+ // messages[] directly. Only the system turn gets the universal rules.
162
+ if (isStructuredMessages(opts.messages)) {
163
+ const systemIdx = opts.messages.findIndex((m) => m.role === 'system');
164
+
165
+ if (systemIdx >= 0 && typeof opts.messages[systemIdx].content === 'string') {
166
+ const existing = opts.messages[systemIdx].content;
167
+ out.messages = opts.messages.map((m, i) => i === systemIdx
168
+ ? { ...m, content: existing ? `${rules}\n\n${existing}` : rules }
169
+ : m);
170
+ } else if (systemIdx < 0) {
171
+ out.messages = [{ role: 'system', content: rules }, ...opts.messages];
172
+ }
173
+
174
+ return out;
175
+ }
156
176
 
157
177
  if (Array.isArray(opts.messages) && opts.messages.length) {
158
178
  const system = opts.messages.find((m) => m.role === 'system');
@@ -170,7 +190,6 @@ function normalizeOptions(opts) {
170
190
 
171
191
  // Prepend universal rules to the system prompt. Patches both representations
172
192
  // (prompt.content and messages[]) since providers read from one or the other.
173
- const rules = SYSTEM_PROMPT_INJECTIONS.join('\n');
174
193
  const existing = stringifyContent(out.prompt?.content || '');
175
194
  const merged = existing ? `${rules}\n\n${existing}` : rules;
176
195
 
@@ -186,6 +205,21 @@ function normalizeOptions(opts) {
186
205
  return out;
187
206
  }
188
207
 
208
+ // A messages[] array is "structured" when it carries turns that cannot survive
209
+ // string-flattening: tool results, assistant tool-call turns, or raw
210
+ // provider content blocks (tool_use / tool_result)
211
+ function isStructuredMessages(messages) {
212
+ if (!Array.isArray(messages) || !messages.length) {
213
+ return false;
214
+ }
215
+
216
+ return messages.some((m) =>
217
+ m.role === 'tool'
218
+ || (Array.isArray(m.toolCalls) && m.toolCalls.length)
219
+ || (Array.isArray(m.content) && m.content.some((c) => c && typeof c === 'object' && (c.type === 'tool_use' || c.type === 'tool_result')))
220
+ );
221
+ }
222
+
189
223
  function stringifyContent(content) {
190
224
  if (typeof content === 'string') {
191
225
  return content;
@@ -205,6 +239,7 @@ const PROVIDERS = {
205
239
  openai: OpenAI,
206
240
  anthropic: Anthropic,
207
241
  'claude-code': ClaudeCode,
242
+ test: TestProvider,
208
243
  };
209
244
 
210
245
  // Expose the underlying provider classes for advanced callers
@@ -212,5 +247,14 @@ AI.providers = PROVIDERS;
212
247
  AI.OpenAI = OpenAI;
213
248
  AI.Anthropic = Anthropic;
214
249
  AI.ClaudeCode = ClaudeCode;
250
+ AI.TestProvider = TestProvider;
215
251
 
216
252
  module.exports = AI;
253
+
254
+ // Exposed for unit tests. Not part of the public API — do not rely on these
255
+ // from consumer code.
256
+ AI._internals = {
257
+ normalizeOptions,
258
+ isStructuredMessages,
259
+ SYSTEM_PROMPT_INJECTIONS,
260
+ };
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Shared pure formatting helpers for the two Claude providers (anthropic,
3
+ * claude-code). Both hit the same Claude Messages API — only auth differs — so
4
+ * the option-shape → request-body mapping lives here once.
5
+ *
6
+ * Handles the unified cross-provider message conventions:
7
+ * - { role: 'system'|'developer'|'user'|'assistant', content: string }
8
+ * - { role: 'assistant', content?, toolCalls: [{ id, name, arguments }] }
9
+ * → assistant turn with tool_use blocks
10
+ * - { role: 'tool', toolCallId, content } → tool_result block; consecutive
11
+ * tool turns merge into ONE user turn (the Messages API requires all
12
+ * results for an assistant turn in a single following user message)
13
+ * - raw Anthropic block arrays (content: [{ type, ... }]) pass through untouched
14
+ *
15
+ * All functions are pure — no network, no SDK — so they're unit-testable
16
+ * without an assistant.
17
+ */
18
+
19
+ /**
20
+ * Map normalized function-tool definitions to Anthropic tool definitions.
21
+ *
22
+ * Accepts entries shaped { name, description, parameters } (optionally with
23
+ * type: 'function'). Provider-specific hosted tools (any other `type`, e.g.
24
+ * OpenAI's { type: 'web_search' }) have no Anthropic equivalent — throw with a
25
+ * clear message instead of silently dropping them.
26
+ *
27
+ * @param {Array} list - options.tools.list
28
+ * @returns {Array<{name, description, input_schema}>}
29
+ */
30
+ function buildToolDefs(list) {
31
+ if (!Array.isArray(list) || !list.length) {
32
+ return [];
33
+ }
34
+
35
+ return list.map((tool) => {
36
+ if (!tool || !tool.name || (tool.type && tool.type !== 'function')) {
37
+ throw new Error(`Anthropic tools must be function tools ({ name, description, parameters }) — got ${JSON.stringify(tool && (tool.type || tool.name) || tool)}`);
38
+ }
39
+
40
+ return {
41
+ name: tool.name,
42
+ description: tool.description || '',
43
+ input_schema: tool.parameters || { type: 'object', properties: {} },
44
+ };
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Map the unified tools.choice value to Anthropic's tool_choice.
50
+ *
51
+ * 'auto' → { type: 'auto' }, 'required' → { type: 'any' },
52
+ * 'none' → { type: 'none' }, { name } → { type: 'tool', name }
53
+ */
54
+ function buildToolChoice(choice) {
55
+ if (!choice) {
56
+ return undefined;
57
+ }
58
+
59
+ if (choice === 'auto') {
60
+ return { type: 'auto' };
61
+ }
62
+
63
+ if (choice === 'required') {
64
+ return { type: 'any' };
65
+ }
66
+
67
+ if (choice === 'none') {
68
+ return { type: 'none' };
69
+ }
70
+
71
+ if (typeof choice === 'object' && choice.name) {
72
+ return { type: 'tool', name: choice.name };
73
+ }
74
+
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * Build Anthropic { system, messages } from the unified option shape.
80
+ *
81
+ * Accepts either:
82
+ * - options.messages: unified turns (see module header)
83
+ * - options.prompt.content (system) + options.message.content (user)
84
+ */
85
+ function buildMessages(options) {
86
+ if (!Array.isArray(options.messages) || !options.messages.length) {
87
+ return {
88
+ system: stringifyContent(options.prompt?.content || ''),
89
+ messages: [{ role: 'user', content: stringifyContent(options.message?.content || '') }],
90
+ };
91
+ }
92
+
93
+ // System: collect system + developer turns (Anthropic has no developer role —
94
+ // fold it into the system prompt, preserving order)
95
+ const system = options.messages
96
+ .filter((m) => m.role === 'system' || m.role === 'developer')
97
+ .map((m) => stringifyContent(m.content))
98
+ .filter(Boolean)
99
+ .join('\n\n');
100
+
101
+ const messages = [];
102
+
103
+ for (const m of options.messages) {
104
+ if (m.role === 'system' || m.role === 'developer') {
105
+ continue;
106
+ }
107
+
108
+ // Tool result turn → tool_result block; merge into the previous user turn
109
+ // if that turn is already a tool-result carrier (consecutive results)
110
+ if (m.role === 'tool') {
111
+ const block = {
112
+ type: 'tool_result',
113
+ tool_use_id: m.toolCallId,
114
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''),
115
+ };
116
+
117
+ const last = messages[messages.length - 1];
118
+
119
+ if (last && last.role === 'user' && Array.isArray(last.content) && last.content.every((c) => c.type === 'tool_result')) {
120
+ last.content.push(block);
121
+ } else {
122
+ messages.push({ role: 'user', content: [block] });
123
+ }
124
+
125
+ continue;
126
+ }
127
+
128
+ // Raw Anthropic block arrays pass through untouched (callers may replay
129
+ // raw.content from a prior response verbatim)
130
+ if (Array.isArray(m.content) && m.content.some((c) => c && typeof c === 'object' && c.type)) {
131
+ messages.push({ role: m.role, content: m.content });
132
+ continue;
133
+ }
134
+
135
+ // Assistant turn with tool calls → text block (if any) + tool_use blocks
136
+ if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
137
+ const content = [];
138
+ const text = stringifyContent(m.content || '');
139
+
140
+ if (text) {
141
+ content.push({ type: 'text', text });
142
+ }
143
+
144
+ for (const call of m.toolCalls) {
145
+ content.push({
146
+ type: 'tool_use',
147
+ id: call.id,
148
+ name: call.name,
149
+ input: parseArguments(call.arguments),
150
+ });
151
+ }
152
+
153
+ messages.push({ role: 'assistant', content });
154
+ continue;
155
+ }
156
+
157
+ // Plain text turn
158
+ messages.push({ role: m.role, content: stringifyContent(m.content) });
159
+ }
160
+
161
+ return { system, messages };
162
+ }
163
+
164
+ /**
165
+ * Extract normalized tool calls from a response's content blocks.
166
+ *
167
+ * @param {Array} content - raw.content from the Messages API
168
+ * @returns {Array<{id, name, arguments}>} arguments is the parsed input object
169
+ */
170
+ function extractToolCalls(content) {
171
+ return (content || [])
172
+ .filter((c) => c.type === 'tool_use')
173
+ .map((c) => ({ id: c.id, name: c.name, arguments: c.input || {} }));
174
+ }
175
+
176
+ /**
177
+ * Map Anthropic stop_reason to the normalized stopReason.
178
+ */
179
+ function mapStopReason(stopReason) {
180
+ if (stopReason === 'tool_use') {
181
+ return 'tool_use';
182
+ }
183
+
184
+ if (stopReason === 'max_tokens') {
185
+ return 'max_tokens';
186
+ }
187
+
188
+ return 'end';
189
+ }
190
+
191
+ function parseArguments(args) {
192
+ if (args && typeof args === 'object') {
193
+ return args;
194
+ }
195
+
196
+ if (typeof args === 'string' && args.trim()) {
197
+ try {
198
+ return JSON.parse(args);
199
+ } catch (e) {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ return {};
205
+ }
206
+
207
+ function stringifyContent(content) {
208
+ if (!content) {
209
+ return '';
210
+ }
211
+
212
+ if (typeof content === 'string') {
213
+ return content;
214
+ }
215
+
216
+ // OpenAI sometimes uses [{ type: 'input_text', text: '...' }] — flatten to string
217
+ if (Array.isArray(content)) {
218
+ return content
219
+ .filter((c) => c.type === 'input_text' || c.type === 'text')
220
+ .map((c) => c.text || '')
221
+ .join('\n');
222
+ }
223
+
224
+ return String(content);
225
+ }
226
+
227
+ module.exports = {
228
+ buildToolDefs,
229
+ buildToolChoice,
230
+ buildMessages,
231
+ extractToolCalls,
232
+ mapStopReason,
233
+ stringifyContent,
234
+ };
@@ -9,6 +9,7 @@
9
9
  */
10
10
  const _ = require('lodash');
11
11
  const JSON5 = require('json5');
12
+ const format = require('./anthropic-format.js');
12
13
 
13
14
  const DEFAULT_MODEL = 'claude-sonnet-4-6';
14
15
 
@@ -58,8 +59,9 @@ Anthropic.prototype.request = async function (options) {
58
59
  const SDK = require('@anthropic-ai/sdk');
59
60
  const client = new SDK({ apiKey: self.key });
60
61
 
61
- // Build messages from the OpenAI-style option shape (prompt + message) or pass-through messages
62
- const { system, messages } = buildMessages(options);
62
+ // Build messages from the OpenAI-style option shape (prompt + message) or
63
+ // unified messages turns (incl. assistant toolCalls + role:'tool' results)
64
+ const { system, messages } = format.buildMessages(options);
63
65
 
64
66
  // JSON output via system prompt instruction (Anthropic's structured output is via prompt, not a flag)
65
67
  let systemFinal = system;
@@ -82,6 +84,20 @@ Anthropic.prototype.request = async function (options) {
82
84
  requestBody.temperature = options.temperature;
83
85
  }
84
86
 
87
+ // Native tool calling — normalized function tools only ({ name, description,
88
+ // parameters }); provider-specific hosted tools throw in buildToolDefs
89
+ const toolDefs = format.buildToolDefs(options.tools?.list);
90
+
91
+ if (toolDefs.length) {
92
+ requestBody.tools = toolDefs;
93
+
94
+ const toolChoice = format.buildToolChoice(options.tools?.choice);
95
+
96
+ if (toolChoice) {
97
+ requestBody.tool_choice = toolChoice;
98
+ }
99
+ }
100
+
85
101
  let raw;
86
102
 
87
103
  try {
@@ -91,13 +107,17 @@ Anthropic.prototype.request = async function (options) {
91
107
  throw e;
92
108
  }
93
109
 
94
- // Extract text from content blocks (concatenate text blocks; ignore tool_use/etc for now)
110
+ // Extract text from content blocks (concatenate text blocks) and tool calls
111
+ // from tool_use blocks — a tool-call turn legitimately has content: ''
95
112
  const outputText = (raw.content || [])
96
113
  .filter((c) => c.type === 'text')
97
114
  .map((c) => c.text.trim())
98
115
  .join('\n')
99
116
  .trim();
100
117
 
118
+ const toolCalls = format.extractToolCalls(raw.content);
119
+ const stopReason = format.mapStopReason(raw.stop_reason);
120
+
101
121
  // Update token counters
102
122
  const modelConfig = MODEL_TABLE[options.model] || MODEL_TABLE[DEFAULT_MODEL];
103
123
 
@@ -108,10 +128,11 @@ Anthropic.prototype.request = async function (options) {
108
128
  self.tokens.output.price = (self.tokens.output.count * modelConfig.output) / 1_000_000;
109
129
  self.tokens.total.price = self.tokens.input.price + self.tokens.output.price;
110
130
 
111
- // Parse JSON if requested
131
+ // Parse JSON if requested — but never on a tool-call turn, where empty/partial
132
+ // text is the normal intermediate state (the caller continues the loop)
112
133
  let parsed = outputText;
113
134
 
114
- if (options.response === 'json') {
135
+ if (options.response === 'json' && !toolCalls.length) {
115
136
  parsed = parseJsonLoose(outputText);
116
137
  }
117
138
 
@@ -120,53 +141,11 @@ Anthropic.prototype.request = async function (options) {
120
141
  content: parsed,
121
142
  tokens: self.tokens,
122
143
  raw,
144
+ toolCalls,
145
+ stopReason,
123
146
  };
124
147
  };
125
148
 
126
- /**
127
- * Build Anthropic system + messages from the unified option shape.
128
- *
129
- * Accepts either:
130
- * - options.messages: [{ role: 'system'|'user'|'assistant', content: string }]
131
- * - options.prompt.content (system) + options.message.content (user)
132
- */
133
- function buildMessages(options) {
134
- // If caller passed `messages` directly (OpenAI-style), translate it
135
- if (Array.isArray(options.messages) && options.messages.length) {
136
- const system = options.messages.find((m) => m.role === 'system')?.content;
137
- const messages = options.messages
138
- .filter((m) => m.role !== 'system')
139
- .map((m) => ({ role: m.role, content: stringifyContent(m.content) }));
140
-
141
- return { system, messages };
142
- }
143
-
144
- // Otherwise build from prompt/message
145
- const system = options.prompt?.content || '';
146
- const userContent = stringifyContent(options.message?.content || '');
147
-
148
- return {
149
- system,
150
- messages: [{ role: 'user', content: userContent }],
151
- };
152
- }
153
-
154
- function stringifyContent(content) {
155
- if (typeof content === 'string') {
156
- return content;
157
- }
158
-
159
- // OpenAI sometimes uses [{ type: 'input_text', text: '...' }] — flatten to string
160
- if (Array.isArray(content)) {
161
- return content
162
- .filter((c) => c.type === 'input_text' || c.type === 'text')
163
- .map((c) => c.text || '')
164
- .join('\n');
165
- }
166
-
167
- return String(content || '');
168
- }
169
-
170
149
  /**
171
150
  * Parse JSON from Claude output. Claude usually obeys the "JSON only" instruction
172
151
  * but occasionally wraps responses in ```json fences or adds a sentence before.
@@ -24,6 +24,7 @@
24
24
  */
25
25
  const _ = require('lodash');
26
26
  const JSON5 = require('json5');
27
+ const format = require('./anthropic-format.js');
27
28
 
28
29
  const DEFAULT_MODEL = 'claude-opus-4-7';
29
30
  const OAUTH_BETA = 'oauth-2025-04-20';
@@ -80,7 +81,7 @@ ClaudeCode.prototype.request = async function (options) {
80
81
  defaultHeaders: { 'anthropic-beta': OAUTH_BETA },
81
82
  });
82
83
 
83
- const { system, messages } = buildMessages(options);
84
+ const { system, messages } = format.buildMessages(options);
84
85
 
85
86
  let systemFinal = system;
86
87
 
@@ -102,6 +103,19 @@ ClaudeCode.prototype.request = async function (options) {
102
103
  requestBody.temperature = options.temperature;
103
104
  }
104
105
 
106
+ // Native tool calling — same normalized interface as the anthropic provider
107
+ const toolDefs = format.buildToolDefs(options.tools?.list);
108
+
109
+ if (toolDefs.length) {
110
+ requestBody.tools = toolDefs;
111
+
112
+ const toolChoice = format.buildToolChoice(options.tools?.choice);
113
+
114
+ if (toolChoice) {
115
+ requestBody.tool_choice = toolChoice;
116
+ }
117
+ }
118
+
105
119
  let raw;
106
120
 
107
121
  try {
@@ -117,6 +131,9 @@ ClaudeCode.prototype.request = async function (options) {
117
131
  .join('\n')
118
132
  .trim();
119
133
 
134
+ const toolCalls = format.extractToolCalls(raw.content);
135
+ const stopReason = format.mapStopReason(raw.stop_reason);
136
+
120
137
  const modelConfig = MODEL_TABLE[options.model] || MODEL_TABLE[DEFAULT_MODEL];
121
138
 
122
139
  self.tokens.input.count += raw.usage?.input_tokens || 0;
@@ -128,7 +145,7 @@ ClaudeCode.prototype.request = async function (options) {
128
145
 
129
146
  let parsed = outputText;
130
147
 
131
- if (options.response === 'json') {
148
+ if (options.response === 'json' && !toolCalls.length) {
132
149
  parsed = parseJsonLoose(outputText);
133
150
  }
134
151
 
@@ -137,54 +154,11 @@ ClaudeCode.prototype.request = async function (options) {
137
154
  content: parsed,
138
155
  tokens: self.tokens,
139
156
  raw,
157
+ toolCalls,
158
+ stopReason,
140
159
  };
141
160
  };
142
161
 
143
- /**
144
- * Build Anthropic system + messages from the unified option shape.
145
- *
146
- * Accepts either:
147
- * - options.messages: [{ role: 'system'|'user'|'assistant', content: string }]
148
- * - options.prompt.content (system) + options.message.content (user)
149
- */
150
- function buildMessages(options) {
151
- if (Array.isArray(options.messages) && options.messages.length) {
152
- const system = options.messages.find((m) => m.role === 'system')?.content;
153
- const messages = options.messages
154
- .filter((m) => m.role !== 'system')
155
- .map((m) => ({ role: m.role, content: stringifyContent(m.content) }));
156
-
157
- return { system: stringifyContent(system), messages };
158
- }
159
-
160
- const system = stringifyContent(options.prompt?.content || '');
161
- const userContent = stringifyContent(options.message?.content || '');
162
-
163
- return {
164
- system,
165
- messages: [{ role: 'user', content: userContent }],
166
- };
167
- }
168
-
169
- function stringifyContent(content) {
170
- if (!content) {
171
- return '';
172
- }
173
-
174
- if (typeof content === 'string') {
175
- return content;
176
- }
177
-
178
- if (Array.isArray(content)) {
179
- return content
180
- .filter((c) => c.type === 'input_text' || c.type === 'text')
181
- .map((c) => c.text || '')
182
- .join('\n');
183
- }
184
-
185
- return String(content);
186
- }
187
-
188
162
  function parseJsonLoose(text) {
189
163
  let cleaned = (text || '').trim();
190
164