@nextsparkjs/plugin-ai 0.1.0-beta.127 → 0.1.0-beta.128
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/lib/core-utils.ts +247 -16
- package/package.json +1 -1
package/lib/core-utils.ts
CHANGED
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
* AI Plugin Core Utilities
|
|
3
3
|
*
|
|
4
4
|
* Simple, direct functions for building AI endpoints
|
|
5
|
+
* Supports both Anthropic API keys and Claude Code OAuth tokens.
|
|
6
|
+
* OAuth tokens use @anthropic-ai/claude-agent-sdk for authentication.
|
|
5
7
|
* No dynamic imports, no complex abstractions
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { createOpenAI } from '@ai-sdk/openai'
|
|
9
11
|
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
10
12
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
|
13
|
+
import { generateText } from 'ai'
|
|
14
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
11
15
|
import type { AIProvider, ModelSelection, AIResult } from '../types/ai.types'
|
|
12
16
|
import {
|
|
13
17
|
getServerPluginConfig,
|
|
14
18
|
isServerPluginEnabled,
|
|
15
19
|
validateServerPluginEnvironment
|
|
16
20
|
} from './server-env'
|
|
21
|
+
import { pluginEnv } from './plugin-env'
|
|
17
22
|
|
|
18
23
|
// Cost per 1K tokens (USD)
|
|
19
24
|
export const COST_CONFIG = {
|
|
@@ -41,9 +46,19 @@ export const COST_CONFIG = {
|
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/**
|
|
44
|
-
* Select AI model and provider
|
|
49
|
+
* Select AI model and provider.
|
|
50
|
+
*
|
|
51
|
+
* @param modelName - Model identifier (e.g. 'claude-sonnet-4-5-20250929', 'gpt-4o')
|
|
52
|
+
* @param provider - Optional provider override. Auto-detected from model name if omitted.
|
|
53
|
+
* @param userApiKey - Optional BYOK key. When provided for the resolved provider,
|
|
54
|
+
* this key is used instead of the global env key. The caller is
|
|
55
|
+
* responsible for fetching it via UserApiKeysService.getDecryptedKey().
|
|
45
56
|
*/
|
|
46
|
-
export async function selectModel(
|
|
57
|
+
export async function selectModel(
|
|
58
|
+
modelName: string,
|
|
59
|
+
provider?: AIProvider,
|
|
60
|
+
userApiKey?: string
|
|
61
|
+
): Promise<ModelSelection> {
|
|
47
62
|
// Auto-detect provider if not specified
|
|
48
63
|
if (!provider) {
|
|
49
64
|
if (modelName.startsWith('gpt-')) {
|
|
@@ -55,57 +70,197 @@ export async function selectModel(modelName: string, provider?: AIProvider): Pro
|
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
|
|
58
|
-
console.log(`🎯 [selectModel] Selected provider: ${provider}, model: ${modelName}`)
|
|
73
|
+
console.log(`🎯 [selectModel] Selected provider: ${provider}, model: ${modelName}${userApiKey ? ' (BYOK)' : ''}`)
|
|
59
74
|
|
|
60
75
|
const costConfig = COST_CONFIG[modelName as keyof typeof COST_CONFIG] || { input: 0, output: 0 }
|
|
61
76
|
const config = await getServerPluginConfig()
|
|
62
77
|
|
|
63
78
|
switch (provider) {
|
|
64
|
-
case 'openai':
|
|
65
|
-
|
|
79
|
+
case 'openai': {
|
|
80
|
+
// Prefer BYOK key when provided, fall back to global config
|
|
81
|
+
const openaiApiKey = userApiKey || config.openaiApiKey
|
|
82
|
+
if (!openaiApiKey) {
|
|
66
83
|
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY in contents/plugins/ai/.env')
|
|
67
84
|
}
|
|
68
85
|
const openaiProvider = createOpenAI({
|
|
69
|
-
apiKey:
|
|
86
|
+
apiKey: openaiApiKey,
|
|
70
87
|
})
|
|
71
88
|
return {
|
|
72
89
|
provider: 'openai',
|
|
73
90
|
model: openaiProvider(modelName),
|
|
74
91
|
modelName,
|
|
75
92
|
isLocal: false,
|
|
76
|
-
costConfig
|
|
93
|
+
costConfig,
|
|
77
94
|
}
|
|
95
|
+
}
|
|
78
96
|
|
|
79
|
-
case 'anthropic':
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
case 'anthropic': {
|
|
98
|
+
// Prefer BYOK key when provided, fall back to global config (API key or OAuth)
|
|
99
|
+
const anthropicAuth = userApiKey || (config.anthropicAuth ?? config.anthropicApiKey)
|
|
100
|
+
if (!anthropicAuth) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Anthropic not configured. Set ANTHROPIC_API_KEY in contents/plugins/ai/.env, or in dev set CLAUDE_CODE_OAUTH_TOKEN (e.g. from Cursor/Claude Code).'
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (!userApiKey && anthropicAuth.startsWith('sk-ant-oat01-') && process.env.NODE_ENV === 'development') {
|
|
106
|
+
console.log('🔐 [selectModel] Using Claude Code OAuth token (dev only)')
|
|
82
107
|
}
|
|
83
108
|
const anthropicProvider = createAnthropic({
|
|
84
|
-
apiKey:
|
|
109
|
+
apiKey: anthropicAuth,
|
|
85
110
|
})
|
|
86
111
|
return {
|
|
87
112
|
provider: 'anthropic',
|
|
88
113
|
model: anthropicProvider(modelName),
|
|
89
114
|
modelName,
|
|
90
115
|
isLocal: false,
|
|
91
|
-
costConfig
|
|
116
|
+
costConfig,
|
|
92
117
|
}
|
|
118
|
+
}
|
|
93
119
|
|
|
94
120
|
case 'ollama':
|
|
95
|
-
default:
|
|
121
|
+
default: {
|
|
96
122
|
const ollamaBaseUrl = config.ollamaBaseUrl || 'http://localhost:11434'
|
|
97
123
|
console.log(`🔥 [selectModel] Creating Ollama provider with baseURL: ${ollamaBaseUrl}, model: ${modelName}`)
|
|
98
124
|
const ollamaProvider = createOpenAICompatible({
|
|
99
125
|
baseURL: `${ollamaBaseUrl}/v1`,
|
|
100
|
-
name: 'ollama'
|
|
126
|
+
name: 'ollama',
|
|
101
127
|
})
|
|
102
128
|
return {
|
|
103
129
|
provider: 'ollama',
|
|
104
130
|
model: ollamaProvider(modelName),
|
|
105
131
|
modelName,
|
|
106
132
|
isLocal: true,
|
|
107
|
-
costConfig
|
|
133
|
+
costConfig,
|
|
108
134
|
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if current auth is an OAuth token (requires Agent SDK)
|
|
141
|
+
*/
|
|
142
|
+
export async function isOAuthMode(): Promise<boolean> {
|
|
143
|
+
const config = await getServerPluginConfig()
|
|
144
|
+
const auth = config.anthropicAuth ?? config.anthropicApiKey
|
|
145
|
+
return !!auth && auth.startsWith('sk-ant-oat01-')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Generate text using Claude Agent SDK (for OAuth tokens)
|
|
150
|
+
* This is used when CLAUDE_CODE_OAUTH_TOKEN is set instead of ANTHROPIC_API_KEY.
|
|
151
|
+
* The Agent SDK handles OAuth authentication internally.
|
|
152
|
+
*/
|
|
153
|
+
export async function generateTextWithAgentSDK(options: {
|
|
154
|
+
system: string
|
|
155
|
+
prompt: string
|
|
156
|
+
model?: string
|
|
157
|
+
maxTokens?: number
|
|
158
|
+
}): Promise<{ text: string; usage: { inputTokens: number; outputTokens: number; totalTokens: number } }> {
|
|
159
|
+
const config = await getServerPluginConfig()
|
|
160
|
+
const model = options.model || config.defaultModel || 'claude-sonnet-4-5-20250929'
|
|
161
|
+
|
|
162
|
+
console.log(`🤖 [AgentSDK] Generating text with model: ${model}`)
|
|
163
|
+
|
|
164
|
+
const conversation = query({
|
|
165
|
+
prompt: options.prompt,
|
|
166
|
+
options: {
|
|
167
|
+
systemPrompt: options.system,
|
|
168
|
+
model,
|
|
169
|
+
maxTurns: 1,
|
|
170
|
+
maxBudgetUsd: 1.0,
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const assistantTextBlocks: string[] = []
|
|
175
|
+
let resultText = ''
|
|
176
|
+
let costUsd = 0
|
|
177
|
+
let actualUsage: { input_tokens: number; output_tokens: number } | null = null
|
|
178
|
+
|
|
179
|
+
for await (const msg of conversation) {
|
|
180
|
+
if (msg.type === 'assistant') {
|
|
181
|
+
const assistantMsg = msg as { type: string; message: { content: Array<{ type: string; text?: string }> } }
|
|
182
|
+
for (const block of assistantMsg.message.content) {
|
|
183
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
184
|
+
assistantTextBlocks.push(block.text.trim())
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (msg.type === 'result') {
|
|
189
|
+
const resultMsg = msg as {
|
|
190
|
+
type: string
|
|
191
|
+
subtype?: string
|
|
192
|
+
total_cost_usd: number
|
|
193
|
+
result?: string
|
|
194
|
+
usage?: { input_tokens: number; output_tokens: number }
|
|
195
|
+
}
|
|
196
|
+
costUsd = resultMsg.total_cost_usd || 0
|
|
197
|
+
if (resultMsg.usage) {
|
|
198
|
+
actualUsage = resultMsg.usage
|
|
199
|
+
}
|
|
200
|
+
if (resultMsg.subtype === 'success' && resultMsg.result) {
|
|
201
|
+
resultText = resultMsg.result.trim()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Prefer result text (from SDKResultSuccess.result) as canonical output.
|
|
207
|
+
// Fall back to concatenated assistant text blocks if result is empty.
|
|
208
|
+
// Use individual last block as final fallback.
|
|
209
|
+
let text = ''
|
|
210
|
+
if (resultText) {
|
|
211
|
+
text = resultText
|
|
212
|
+
} else if (assistantTextBlocks.length > 0) {
|
|
213
|
+
// Join all text blocks — some responses split content across multiple blocks
|
|
214
|
+
text = assistantTextBlocks.join('\n')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Use actual usage from the SDK when available, fall back to cost estimation
|
|
218
|
+
let inputTokens: number
|
|
219
|
+
let outputTokens: number
|
|
220
|
+
if (actualUsage) {
|
|
221
|
+
inputTokens = actualUsage.input_tokens
|
|
222
|
+
outputTokens = actualUsage.output_tokens
|
|
223
|
+
} else {
|
|
224
|
+
const estimatedTokens = Math.round(costUsd / 0.000015) || 100
|
|
225
|
+
inputTokens = Math.round(estimatedTokens * 0.3)
|
|
226
|
+
outputTokens = Math.round(estimatedTokens * 0.7)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(`✅ [AgentSDK] Generated ${text.length} chars, cost: $${costUsd.toFixed(4)}, resultText: ${resultText.length} chars, assistantBlocks: ${assistantTextBlocks.length}`)
|
|
230
|
+
|
|
231
|
+
// Fallback: if Agent SDK returned empty text despite reporting output tokens,
|
|
232
|
+
// retry with direct Anthropic API via @ai-sdk/anthropic (uses the same OAuth token).
|
|
233
|
+
if (!text) {
|
|
234
|
+
console.warn(`⚠️ [AgentSDK] Empty text with ${outputTokens} output tokens — falling back to direct Anthropic API`)
|
|
235
|
+
try {
|
|
236
|
+
const anthropicAuth = config.anthropicAuth ?? config.anthropicApiKey
|
|
237
|
+
if (anthropicAuth) {
|
|
238
|
+
const anthropicProvider = createAnthropic({ apiKey: anthropicAuth })
|
|
239
|
+
const fallbackResult = await generateText({
|
|
240
|
+
model: anthropicProvider(model),
|
|
241
|
+
system: options.system,
|
|
242
|
+
prompt: options.prompt,
|
|
243
|
+
maxOutputTokens: options.maxTokens || 4096,
|
|
244
|
+
temperature: 0.3,
|
|
245
|
+
})
|
|
246
|
+
text = fallbackResult.text
|
|
247
|
+
inputTokens = fallbackResult.usage?.inputTokens || inputTokens
|
|
248
|
+
outputTokens = fallbackResult.usage?.outputTokens || outputTokens
|
|
249
|
+
console.log(`✅ [AgentSDK/fallback] Generated ${text.length} chars via direct API`)
|
|
250
|
+
}
|
|
251
|
+
} catch (fallbackError) {
|
|
252
|
+
console.error(`❌ [AgentSDK/fallback] Direct API also failed:`, fallbackError)
|
|
253
|
+
// Let the caller handle the empty text
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
text,
|
|
259
|
+
usage: {
|
|
260
|
+
inputTokens,
|
|
261
|
+
outputTokens,
|
|
262
|
+
totalTokens: inputTokens + outputTokens,
|
|
263
|
+
},
|
|
109
264
|
}
|
|
110
265
|
}
|
|
111
266
|
|
|
@@ -121,6 +276,82 @@ export function calculateCost(
|
|
|
121
276
|
return Math.round((inputCost + outputCost) * 100000) / 100000
|
|
122
277
|
}
|
|
123
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Check whether the user/team has exceeded their configured AI cost limits.
|
|
281
|
+
*
|
|
282
|
+
* Queries `langchain_token_usage` for the running daily and monthly totals and
|
|
283
|
+
* compares them against `AI_PLUGIN_DAILY_COST_LIMIT` / `AI_PLUGIN_MONTHLY_COST_LIMIT`.
|
|
284
|
+
*
|
|
285
|
+
* This is a *soft* enforcement: if the table does not exist, is unreachable, or
|
|
286
|
+
* if cost tracking is disabled in the plugin config the function resolves without
|
|
287
|
+
* error so that normal execution continues.
|
|
288
|
+
*
|
|
289
|
+
* @param userId - The authenticated user ID
|
|
290
|
+
* @param teamId - The team ID for multi-tenancy context
|
|
291
|
+
* @throws Error with a descriptive message when a hard limit is exceeded
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* await checkCostLimits(userId, teamId) // throws if over limit, otherwise no-op
|
|
295
|
+
*/
|
|
296
|
+
export async function checkCostLimits(userId: string, teamId: string): Promise<void> {
|
|
297
|
+
// Respect the cost-tracking enabled flag — do nothing when disabled
|
|
298
|
+
if (!pluginEnv.isCostTrackingEnabled()) return
|
|
299
|
+
|
|
300
|
+
const dailyLimit = pluginEnv.getDailyCostLimit()
|
|
301
|
+
const monthlyLimit = pluginEnv.getMonthlyCostLimit()
|
|
302
|
+
|
|
303
|
+
// Both limits at 0 means "no enforcement"
|
|
304
|
+
if (dailyLimit <= 0 && monthlyLimit <= 0) return
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// Lazy import to avoid pulling in DB dependencies on the client bundle.
|
|
308
|
+
// The table lives in the LangChain plugin so we import from there.
|
|
309
|
+
const { queryWithRLS } = await import('@nextsparkjs/core/lib/db')
|
|
310
|
+
|
|
311
|
+
// Single query fetches both day and month totals in one round-trip
|
|
312
|
+
const rows = await queryWithRLS<{
|
|
313
|
+
dailyCost: string | null
|
|
314
|
+
monthlyCost: string | null
|
|
315
|
+
}>(
|
|
316
|
+
`SELECT
|
|
317
|
+
COALESCE(SUM(CASE WHEN "createdAt" >= CURRENT_DATE THEN "totalCost" ELSE 0 END), 0)::text AS "dailyCost",
|
|
318
|
+
COALESCE(SUM(CASE WHEN "createdAt" >= date_trunc('month', now()) THEN "totalCost" ELSE 0 END), 0)::text AS "monthlyCost"
|
|
319
|
+
FROM public."langchain_token_usage"
|
|
320
|
+
WHERE "userId" = $1 AND "teamId" = $2`,
|
|
321
|
+
[userId, teamId],
|
|
322
|
+
userId,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if (!rows || rows.length === 0) return
|
|
326
|
+
|
|
327
|
+
const dailyCost = parseFloat(rows[0].dailyCost ?? '0')
|
|
328
|
+
const monthlyCost = parseFloat(rows[0].monthlyCost ?? '0')
|
|
329
|
+
|
|
330
|
+
if (dailyLimit > 0 && dailyCost >= dailyLimit) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Daily AI cost limit reached ($${dailyCost.toFixed(4)} of $${dailyLimit.toFixed(2)} used). ` +
|
|
333
|
+
'Try again tomorrow or contact support to increase your limit.',
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (monthlyLimit > 0 && monthlyCost >= monthlyLimit) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Monthly AI cost limit reached ($${monthlyCost.toFixed(4)} of $${monthlyLimit.toFixed(2)} used). ` +
|
|
340
|
+
'Upgrade your plan or contact support to increase your limit.',
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
// If the error is one we threw ourselves (limit exceeded), re-throw it
|
|
345
|
+
if (error instanceof Error && (error.message.includes('limit reached') || error.message.includes('Cost limit'))) {
|
|
346
|
+
throw error
|
|
347
|
+
}
|
|
348
|
+
// Any other error (DB down, table missing, etc.) — block the request for safety.
|
|
349
|
+
// It is not safe to proceed without being able to verify cost limits.
|
|
350
|
+
console.error('[checkCostLimits] Cannot verify cost limits - blocking request for safety:', error)
|
|
351
|
+
throw new Error('Cost limit verification unavailable. Please try again shortly.')
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
124
355
|
/**
|
|
125
356
|
* Validate plugin is ready to use
|
|
126
357
|
*/
|
|
@@ -214,4 +445,4 @@ export function handleAIError(error: Error): { error: string; message: string; s
|
|
|
214
445
|
message: error.message,
|
|
215
446
|
status: 500
|
|
216
447
|
}
|
|
217
|
-
}
|
|
448
|
+
}
|