backend-manager 5.0.203 → 5.1.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 +56 -0
- package/CLAUDE.md +43 -1501
- package/TODO-CHARGEBLAST.md +32 -0
- package/TODO-email-auth.md +14 -0
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-operations.md +64 -0
- package/docs/directory-structure.md +119 -0
- package/docs/environment-detection.md +7 -0
- package/docs/file-naming.md +11 -0
- package/docs/marketing-campaigns.md +244 -0
- package/docs/marketing-fields.md +25 -0
- package/docs/mcp.md +95 -0
- package/docs/payment-system.md +325 -0
- package/docs/response-headers.md +7 -0
- package/docs/routes.md +126 -0
- package/docs/sanitization.md +61 -0
- package/docs/schemas.md +39 -0
- package/docs/stripe-webhook-forwarding.md +18 -0
- package/docs/testing.md +129 -0
- package/docs/usage-rate-limiting.md +67 -0
- package/package.json +8 -4
- package/src/defaults/CHANGELOG.md +15 -0
- package/src/defaults/CLAUDE.md +8 -4
- package/src/defaults/docs/README.md +17 -0
- package/src/defaults/test/README.md +33 -0
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
- package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
- package/src/manager/helpers/utilities.js +21 -0
- package/src/manager/index.js +1 -1
- package/src/manager/libraries/ai/index.js +162 -0
- package/src/manager/libraries/ai/providers/anthropic.js +193 -0
- package/src/manager/libraries/ai/providers/claude-code.js +206 -0
- package/src/manager/libraries/ai/providers/openai.js +934 -0
- package/src/manager/libraries/disposable-domains.json +2 -0
- package/src/manager/libraries/email/generators/lib/filter.js +179 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
- package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
- package/src/manager/libraries/email/generators/lib/structure.js +278 -0
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
- package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
- package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
- package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
- package/src/manager/libraries/email/generators/newsletter.js +377 -95
- package/src/manager/libraries/email/marketing/index.js +5 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- package/src/manager/libraries/openai.js +13 -932
- package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
- package/src/manager/routes/admin/post/post.js +10 -17
- package/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- package/test/helpers/slugify.js +394 -0
- package/test/marketing/fixtures/clean.json +31 -0
- package/test/marketing/fixtures/editorial.json +31 -0
- package/test/marketing/fixtures/field-report.json +54 -0
- package/test/marketing/newsletter-generate.js +731 -0
- package/test/marketing/newsletter-templates.js +512 -0
- package/test/routes/admin/deduplicate-image-alts.js +190 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified AI library — provider-agnostic surface for OpenAI, Anthropic, etc.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const ai = Manager.AI(assistant);
|
|
6
|
+
* const result = await ai.request({ provider: 'openai', model: 'gpt-5-mini', ... });
|
|
7
|
+
* const result = await ai.request({ provider: 'anthropic', model: 'claude-sonnet-4-6', ... });
|
|
8
|
+
*
|
|
9
|
+
* Each provider returns { content, output, tokens, raw } with a consistent shape.
|
|
10
|
+
*
|
|
11
|
+
* Default provider: openai (preserves backward compatibility with the old openai.js surface).
|
|
12
|
+
*/
|
|
13
|
+
const OpenAI = require('./providers/openai.js');
|
|
14
|
+
const Anthropic = require('./providers/anthropic.js');
|
|
15
|
+
const ClaudeCode = require('./providers/claude-code.js');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PROVIDER = 'openai';
|
|
18
|
+
|
|
19
|
+
function AI(assistant, key) {
|
|
20
|
+
const self = this;
|
|
21
|
+
|
|
22
|
+
self.assistant = assistant;
|
|
23
|
+
self.Manager = assistant?.Manager;
|
|
24
|
+
|
|
25
|
+
// Lazily instantiate providers — only the ones actually used pay the cost
|
|
26
|
+
self._providers = {};
|
|
27
|
+
self._defaultKey = key;
|
|
28
|
+
|
|
29
|
+
// Combined token counter across all provider calls in this AI instance
|
|
30
|
+
self.tokens = {
|
|
31
|
+
total: { count: 0, price: 0 },
|
|
32
|
+
input: { count: 0, price: 0 },
|
|
33
|
+
output: { count: 0, price: 0 },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return self;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Make an AI request. Dispatches to the configured provider.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} options
|
|
43
|
+
* @param {'openai'|'anthropic'} [options.provider='openai']
|
|
44
|
+
* @param {string} [options.model]
|
|
45
|
+
* @param {string} [options.apiKey] - override provider-specific key
|
|
46
|
+
* @param {Array<{role,content}>} [options.messages]
|
|
47
|
+
* @param {object} [options.prompt] - { content: 'system prompt' }
|
|
48
|
+
* @param {object} [options.message] - { content: 'user message' }
|
|
49
|
+
* @param {'json'|'text'} [options.response]
|
|
50
|
+
* @param {object} [options.schema] - JSON schema for structured output
|
|
51
|
+
* @param {number} [options.maxTokens]
|
|
52
|
+
* @param {number} [options.temperature]
|
|
53
|
+
* @returns {Promise<{content, output, tokens, raw}>}
|
|
54
|
+
*/
|
|
55
|
+
AI.prototype.request = async function (options) {
|
|
56
|
+
const self = this;
|
|
57
|
+
const provider = (options || {}).provider || DEFAULT_PROVIDER;
|
|
58
|
+
|
|
59
|
+
// Normalize unified options shape into what each provider expects.
|
|
60
|
+
// Callers can pass either `messages: [{ role, content }]` (standard SDK style)
|
|
61
|
+
// or BEM's legacy `prompt.content` / `message.content`.
|
|
62
|
+
const normalized = normalizeOptions(options || {});
|
|
63
|
+
|
|
64
|
+
const client = self._getProvider(provider, normalized.apiKey);
|
|
65
|
+
const result = await client.request(normalized);
|
|
66
|
+
|
|
67
|
+
// Roll provider's token counts into the combined counter (best-effort — different
|
|
68
|
+
// providers report tokens slightly differently)
|
|
69
|
+
if (result?.tokens?.input?.count) {
|
|
70
|
+
self.tokens.input.count += result.tokens.input.count - (self._lastTokens?.[provider]?.input || 0);
|
|
71
|
+
self.tokens.output.count += result.tokens.output.count - (self._lastTokens?.[provider]?.output || 0);
|
|
72
|
+
self.tokens.input.price += result.tokens.input.price - (self._lastTokens?.[provider]?.inputPrice || 0);
|
|
73
|
+
self.tokens.output.price += result.tokens.output.price - (self._lastTokens?.[provider]?.outputPrice || 0);
|
|
74
|
+
self.tokens.total.count = self.tokens.input.count + self.tokens.output.count;
|
|
75
|
+
self.tokens.total.price = self.tokens.input.price + self.tokens.output.price;
|
|
76
|
+
|
|
77
|
+
self._lastTokens = self._lastTokens || {};
|
|
78
|
+
self._lastTokens[provider] = {
|
|
79
|
+
input: result.tokens.input.count,
|
|
80
|
+
output: result.tokens.output.count,
|
|
81
|
+
inputPrice: result.tokens.input.price,
|
|
82
|
+
outputPrice: result.tokens.output.price,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
AI.prototype._getProvider = function (provider, apiKey) {
|
|
90
|
+
const self = this;
|
|
91
|
+
|
|
92
|
+
if (!self._providers[provider]) {
|
|
93
|
+
const Provider = PROVIDERS[provider];
|
|
94
|
+
|
|
95
|
+
if (!Provider) {
|
|
96
|
+
throw new Error(`Unknown AI provider: ${provider}. Supported: ${Object.keys(PROVIDERS).join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
self._providers[provider] = new Provider(self.assistant, apiKey || self._defaultKey);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return self._providers[provider];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Translate a unified options object into the shape each provider expects.
|
|
107
|
+
*
|
|
108
|
+
* Accepts:
|
|
109
|
+
* - messages: [{ role: 'system'|'user'|'assistant', content: string }]
|
|
110
|
+
* - OR prompt.content (system) + message.content (user)
|
|
111
|
+
*
|
|
112
|
+
* Returns options with BOTH styles populated, so OpenAI's `prompt`/`message`
|
|
113
|
+
* fields and Anthropic's `messages` array both work.
|
|
114
|
+
*/
|
|
115
|
+
function normalizeOptions(opts) {
|
|
116
|
+
const out = { ...opts };
|
|
117
|
+
|
|
118
|
+
if (Array.isArray(opts.messages) && opts.messages.length) {
|
|
119
|
+
const system = opts.messages.find((m) => m.role === 'system');
|
|
120
|
+
const userTurns = opts.messages.filter((m) => m.role !== 'system');
|
|
121
|
+
const lastUser = userTurns[userTurns.length - 1];
|
|
122
|
+
|
|
123
|
+
if (system && !out.prompt?.content) {
|
|
124
|
+
out.prompt = { ...(out.prompt || {}), content: stringifyContent(system.content) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (lastUser && !out.message?.content) {
|
|
128
|
+
out.message = { ...(out.message || {}), content: stringifyContent(lastUser.content) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stringifyContent(content) {
|
|
136
|
+
if (typeof content === 'string') {
|
|
137
|
+
return content;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(content)) {
|
|
141
|
+
return content
|
|
142
|
+
.filter((c) => c.type === 'input_text' || c.type === 'text')
|
|
143
|
+
.map((c) => c.text || '')
|
|
144
|
+
.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return String(content || '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const PROVIDERS = {
|
|
151
|
+
openai: OpenAI,
|
|
152
|
+
anthropic: Anthropic,
|
|
153
|
+
'claude-code': ClaudeCode,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Expose the underlying provider classes for advanced callers
|
|
157
|
+
AI.providers = PROVIDERS;
|
|
158
|
+
AI.OpenAI = OpenAI;
|
|
159
|
+
AI.Anthropic = Anthropic;
|
|
160
|
+
AI.ClaudeCode = ClaudeCode;
|
|
161
|
+
|
|
162
|
+
module.exports = AI;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic provider for the unified AI library.
|
|
3
|
+
*
|
|
4
|
+
* Public surface matches the OpenAI provider:
|
|
5
|
+
* new Anthropic(assistant, key).request(options) → { content, output, tokens, raw }
|
|
6
|
+
*
|
|
7
|
+
* Maps the Claude Messages API onto the OpenAI provider's option shape so callers
|
|
8
|
+
* can swap providers without rewriting call sites.
|
|
9
|
+
*/
|
|
10
|
+
const _ = require('lodash');
|
|
11
|
+
const JSON5 = require('json5');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
14
|
+
|
|
15
|
+
// Pricing per 1M tokens (USD). Update when Anthropic changes their pricing page.
|
|
16
|
+
const MODEL_TABLE = {
|
|
17
|
+
'claude-opus-4-7': { input: 15.00, output: 75.00, features: { json: true, temperature: true, reasoning: true } },
|
|
18
|
+
'claude-opus-4-6': { input: 15.00, output: 75.00, features: { json: true, temperature: true, reasoning: true } },
|
|
19
|
+
'claude-sonnet-4-6': { input: 3.00, output: 15.00, features: { json: true, temperature: true, reasoning: true } },
|
|
20
|
+
'claude-haiku-4-5': { input: 1.00, output: 5.00, features: { json: true, temperature: true, reasoning: false } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function Anthropic(assistant, key) {
|
|
24
|
+
const self = this;
|
|
25
|
+
|
|
26
|
+
self.assistant = assistant;
|
|
27
|
+
self.Manager = assistant?.Manager;
|
|
28
|
+
self.user = assistant?.user;
|
|
29
|
+
self.key = key
|
|
30
|
+
|| self.Manager?.config?.anthropic?.key
|
|
31
|
+
|| process.env.ANTHROPIC_API_KEY
|
|
32
|
+
|| process.env.BACKEND_MANAGER_ANTHROPIC_API_KEY;
|
|
33
|
+
|
|
34
|
+
self.tokens = {
|
|
35
|
+
total: { count: 0, price: 0 },
|
|
36
|
+
input: { count: 0, price: 0 },
|
|
37
|
+
output: { count: 0, price: 0 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return self;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Anthropic.prototype.request = async function (options) {
|
|
44
|
+
const self = this;
|
|
45
|
+
const assistant = self.assistant;
|
|
46
|
+
|
|
47
|
+
options = _.merge({}, options);
|
|
48
|
+
options.model = options.model || DEFAULT_MODEL;
|
|
49
|
+
options.maxTokens = options.maxTokens || 2048;
|
|
50
|
+
options.temperature = typeof options.temperature === 'undefined' ? 0.7 : options.temperature;
|
|
51
|
+
options.timeout = options.timeout || 120000;
|
|
52
|
+
|
|
53
|
+
if (!self.key) {
|
|
54
|
+
throw new Error('Anthropic API key not configured (set BACKEND_MANAGER_ANTHROPIC_API_KEY)');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Lazy-require the SDK so projects that don't use Anthropic don't need it installed
|
|
58
|
+
const SDK = require('@anthropic-ai/sdk');
|
|
59
|
+
const client = new SDK({ apiKey: self.key });
|
|
60
|
+
|
|
61
|
+
// Build messages from the OpenAI-style option shape (prompt + message) or pass-through messages
|
|
62
|
+
const { system, messages } = buildMessages(options);
|
|
63
|
+
|
|
64
|
+
// JSON output via system prompt instruction (Anthropic's structured output is via prompt, not a flag)
|
|
65
|
+
let systemFinal = system;
|
|
66
|
+
|
|
67
|
+
if (options.response === 'json') {
|
|
68
|
+
systemFinal = `${systemFinal || ''}\n\nYou MUST respond with valid JSON only. No prose, no markdown fences, no explanation — just the JSON object.${options.schema ? `\n\nThe JSON must conform to this schema:\n${JSON.stringify(options.schema)}` : ''}`.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const requestBody = {
|
|
72
|
+
model: options.model,
|
|
73
|
+
max_tokens: options.maxTokens,
|
|
74
|
+
messages,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (systemFinal) {
|
|
78
|
+
requestBody.system = systemFinal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (options.temperature !== undefined) {
|
|
82
|
+
requestBody.temperature = options.temperature;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let raw;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
raw = await client.messages.create(requestBody);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
assistant?.error?.(`Anthropic request failed: ${e.message}`, e);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract text from content blocks (concatenate text blocks; ignore tool_use/etc for now)
|
|
95
|
+
const outputText = (raw.content || [])
|
|
96
|
+
.filter((c) => c.type === 'text')
|
|
97
|
+
.map((c) => c.text.trim())
|
|
98
|
+
.join('\n')
|
|
99
|
+
.trim();
|
|
100
|
+
|
|
101
|
+
// Update token counters
|
|
102
|
+
const modelConfig = MODEL_TABLE[options.model] || MODEL_TABLE[DEFAULT_MODEL];
|
|
103
|
+
|
|
104
|
+
self.tokens.input.count += raw.usage?.input_tokens || 0;
|
|
105
|
+
self.tokens.output.count += raw.usage?.output_tokens || 0;
|
|
106
|
+
self.tokens.total.count = self.tokens.input.count + self.tokens.output.count;
|
|
107
|
+
self.tokens.input.price = (self.tokens.input.count * modelConfig.input) / 1_000_000;
|
|
108
|
+
self.tokens.output.price = (self.tokens.output.count * modelConfig.output) / 1_000_000;
|
|
109
|
+
self.tokens.total.price = self.tokens.input.price + self.tokens.output.price;
|
|
110
|
+
|
|
111
|
+
// Parse JSON if requested
|
|
112
|
+
let parsed = outputText;
|
|
113
|
+
|
|
114
|
+
if (options.response === 'json') {
|
|
115
|
+
parsed = parseJsonLoose(outputText);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
output: raw.content || [],
|
|
120
|
+
content: parsed,
|
|
121
|
+
tokens: self.tokens,
|
|
122
|
+
raw,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
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
|
+
/**
|
|
171
|
+
* Parse JSON from Claude output. Claude usually obeys the "JSON only" instruction
|
|
172
|
+
* but occasionally wraps responses in ```json fences or adds a sentence before.
|
|
173
|
+
* Strip fences and try JSON5 for robustness.
|
|
174
|
+
*/
|
|
175
|
+
function parseJsonLoose(text) {
|
|
176
|
+
let cleaned = text.trim();
|
|
177
|
+
|
|
178
|
+
// Strip ```json ... ``` fences
|
|
179
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
180
|
+
|
|
181
|
+
// Find first { or [ and last } or ] to handle preamble text
|
|
182
|
+
const firstBrace = Math.min(
|
|
183
|
+
...[cleaned.indexOf('{'), cleaned.indexOf('[')].filter((i) => i >= 0),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (firstBrace > 0) {
|
|
187
|
+
cleaned = cleaned.slice(firstBrace);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return JSON5.parse(cleaned);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = Anthropic;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code provider — uses @anthropic-ai/claude-agent-sdk to call Claude via
|
|
3
|
+
* the local user's Claude Code subscription (no API key, no Anthropic credits).
|
|
4
|
+
*
|
|
5
|
+
* Pass `forceLoginMethod: 'claudeai'` so the SDK auths via the OS keychain
|
|
6
|
+
* OAuth session that the user already logged into via the `claude` CLI.
|
|
7
|
+
*
|
|
8
|
+
* This is strictly a local-development provider:
|
|
9
|
+
* - Requires a logged-in Claude Code session on the host machine
|
|
10
|
+
* - Will not work in Cloud Functions / CI / production
|
|
11
|
+
* - Subject to your Claude Pro/Max rate limits (not API-tier limits)
|
|
12
|
+
*
|
|
13
|
+
* Returns the same { content, output, tokens, raw } shape as the other providers
|
|
14
|
+
* so callers don't care which is in use.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_MODEL = 'claude-opus-4-7';
|
|
17
|
+
|
|
18
|
+
// Lazy import — only load the SDK if this provider is actually used
|
|
19
|
+
let _query;
|
|
20
|
+
|
|
21
|
+
function loadQuery() {
|
|
22
|
+
if (!_query) {
|
|
23
|
+
_query = require('@anthropic-ai/claude-agent-sdk').query;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return _query;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ClaudeCode(assistant, key) {
|
|
30
|
+
const self = this;
|
|
31
|
+
|
|
32
|
+
self.assistant = assistant;
|
|
33
|
+
self.Manager = assistant?.Manager;
|
|
34
|
+
self.user = assistant?.user;
|
|
35
|
+
// key is ignored — claude-code uses OS keychain OAuth via forceLoginMethod: 'claudeai'
|
|
36
|
+
|
|
37
|
+
self.tokens = {
|
|
38
|
+
total: { count: 0, price: 0 },
|
|
39
|
+
input: { count: 0, price: 0 },
|
|
40
|
+
output: { count: 0, price: 0 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return self;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ClaudeCode.prototype.request = async function (options) {
|
|
47
|
+
const self = this;
|
|
48
|
+
const assistant = self.assistant;
|
|
49
|
+
|
|
50
|
+
options = options || {};
|
|
51
|
+
const model = options.model || DEFAULT_MODEL;
|
|
52
|
+
|
|
53
|
+
// Build prompt + system from the unified options shape
|
|
54
|
+
const { system, prompt } = extractPromptAndSystem(options);
|
|
55
|
+
|
|
56
|
+
if (!prompt) {
|
|
57
|
+
throw new Error('claude-code provider requires options.message.content or options.messages with a user turn');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const query = loadQuery();
|
|
61
|
+
const startTime = Date.now();
|
|
62
|
+
|
|
63
|
+
// Build SDK options
|
|
64
|
+
const sdkOptions = {
|
|
65
|
+
model,
|
|
66
|
+
forceLoginMethod: 'claudeai', // Use Claude Pro/Max subscription, not API key
|
|
67
|
+
allowedTools: [], // Disable all built-in tools — we just want text/JSON in/out
|
|
68
|
+
settingSources: [], // Don't load .claude/ or ~/.claude/ settings
|
|
69
|
+
includePartialMessages: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (system) {
|
|
73
|
+
sdkOptions.systemPrompt = system;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.response === 'json' && options.schema) {
|
|
77
|
+
sdkOptions.outputFormat = {
|
|
78
|
+
type: 'json_schema',
|
|
79
|
+
schema: options.schema,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let resultText = '';
|
|
84
|
+
let structuredOutput = null;
|
|
85
|
+
let usage = null;
|
|
86
|
+
let totalCostUSD = 0;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for await (const message of query({ prompt, options: sdkOptions })) {
|
|
90
|
+
// Collect text from assistant messages
|
|
91
|
+
if (message.type === 'assistant') {
|
|
92
|
+
for (const block of message.message?.content || []) {
|
|
93
|
+
if (block.type === 'text') {
|
|
94
|
+
resultText += block.text;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Capture final result + usage from the result message
|
|
100
|
+
if (message.type === 'result') {
|
|
101
|
+
if (message.subtype === 'success') {
|
|
102
|
+
structuredOutput = message.structured_output || null;
|
|
103
|
+
resultText = message.result || resultText;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
usage = message.usage;
|
|
107
|
+
totalCostUSD = message.total_cost_usd || 0;
|
|
108
|
+
|
|
109
|
+
if (message.is_error) {
|
|
110
|
+
throw new Error(`claude-code: ${resultText || 'unknown error'}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
assistant?.error?.(`claude-code request failed: ${e.message}`);
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Update token counters
|
|
120
|
+
if (usage) {
|
|
121
|
+
self.tokens.input.count += usage.input_tokens || 0;
|
|
122
|
+
self.tokens.output.count += usage.output_tokens || 0;
|
|
123
|
+
self.tokens.total.count = self.tokens.input.count + self.tokens.output.count;
|
|
124
|
+
self.tokens.total.price = totalCostUSD;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Resolve content — prefer structured_output (validated against schema)
|
|
128
|
+
let content;
|
|
129
|
+
|
|
130
|
+
if (structuredOutput != null) {
|
|
131
|
+
content = structuredOutput;
|
|
132
|
+
} else if (options.response === 'json') {
|
|
133
|
+
content = parseJsonLoose(resultText);
|
|
134
|
+
} else {
|
|
135
|
+
content = resultText;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
assistant?.log?.(`claude-code: ${Date.now() - startTime}ms, ${usage?.output_tokens || 0} output tokens, $${totalCostUSD?.toFixed(4) || '0.0000'}`);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
output: [{ type: 'output_text', text: resultText }],
|
|
142
|
+
content,
|
|
143
|
+
tokens: self.tokens,
|
|
144
|
+
raw: { usage, totalCostUSD },
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Map unified options into a system prompt + a single user prompt string.
|
|
150
|
+
*/
|
|
151
|
+
function extractPromptAndSystem(options) {
|
|
152
|
+
if (Array.isArray(options.messages) && options.messages.length) {
|
|
153
|
+
const system = options.messages.find((m) => m.role === 'system')?.content;
|
|
154
|
+
const lastUser = [...options.messages].reverse().find((m) => m.role !== 'system');
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
system: stringifyContent(system),
|
|
158
|
+
prompt: stringifyContent(lastUser?.content),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
system: stringifyContent(options.prompt?.content),
|
|
164
|
+
prompt: stringifyContent(options.message?.content),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stringifyContent(content) {
|
|
169
|
+
if (!content) {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (typeof content === 'string') {
|
|
174
|
+
return content;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (Array.isArray(content)) {
|
|
178
|
+
return content
|
|
179
|
+
.filter((c) => c.type === 'input_text' || c.type === 'text')
|
|
180
|
+
.map((c) => c.text || '')
|
|
181
|
+
.join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return String(content);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseJsonLoose(text) {
|
|
188
|
+
if (!text) return text;
|
|
189
|
+
|
|
190
|
+
let cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
191
|
+
const firstObj = cleaned.indexOf('{');
|
|
192
|
+
const firstArr = cleaned.indexOf('[');
|
|
193
|
+
const start = [firstObj, firstArr].filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
194
|
+
|
|
195
|
+
if (start > 0) {
|
|
196
|
+
cleaned = cleaned.slice(start);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(cleaned);
|
|
201
|
+
} catch {
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = ClaudeCode;
|