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.
- package/CHANGELOG.md +43 -0
- package/CLAUDE.md +4 -3
- package/PROGRESS.md +34 -0
- package/docs/ai-library.md +62 -11
- package/docs/cdp-debugging.md +44 -0
- package/docs/cli-output.md +22 -10
- package/docs/mcp.md +166 -43
- package/docs/test-framework.md +2 -2
- package/package.json +1 -1
- package/plans/mcp2.md +247 -0
- package/src/cli/commands/mcp.js +8 -2
- package/src/cli/commands/serve.js +155 -29
- package/src/cli/commands/setup-tests/base-test.js +8 -0
- package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
- package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
- package/src/cli/commands/setup-tests/index.js +4 -0
- package/src/cli/commands/setup-tests/java-installed.js +26 -0
- package/src/cli/commands/setup.js +2 -1
- package/src/cli/commands/test.js +13 -0
- package/src/cli/index.js +14 -0
- package/src/cli/utils/ui.js +27 -5
- package/src/manager/index.js +8 -3
- package/src/manager/libraries/ai/index.js +45 -1
- package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
- package/src/manager/libraries/ai/providers/anthropic.js +28 -49
- package/src/manager/libraries/ai/providers/claude-code.js +21 -47
- package/src/manager/libraries/ai/providers/openai.js +154 -19
- package/src/manager/libraries/ai/providers/test.js +242 -0
- package/src/manager/libraries/email/data/disposable-domains.json +465 -0
- package/src/mcp/client.js +48 -13
- package/src/mcp/handler.js +222 -69
- package/src/mcp/index.js +48 -18
- package/src/mcp/tools.js +150 -0
- package/src/mcp/utils.js +108 -0
- package/src/test/fixtures/firebase-project/firebase.json +1 -1
- package/src/test/test-accounts.js +31 -0
- package/test/ai/tools-live.js +170 -0
- package/test/email/marketing-lifecycle.js +10 -5
- package/test/helpers/ai-test-provider.js +202 -0
- package/test/helpers/ai-tools-format.js +350 -0
- package/test/mcp/discovery.js +53 -0
- package/test/mcp/oauth.js +161 -0
- package/test/mcp/protocol.js +268 -0
- package/test/mcp/roles.js +168 -0
- package/test/mcp/utils.js +245 -0
- package/test/routes/marketing/webhook.js +37 -33
- package/.claude/settings.local.json +0 -12
package/src/cli/commands/test.js
CHANGED
|
@@ -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++;
|
package/src/cli/utils/ui.js
CHANGED
|
@@ -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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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();
|
package/src/manager/index.js
CHANGED
|
@@ -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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
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
|
|