@nextsparkjs/plugin-ai 0.1.0-beta.126 → 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.
Files changed (2) hide show
  1. package/lib/core-utils.ts +247 -16
  2. 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(modelName: string, provider?: AIProvider): Promise<ModelSelection> {
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
- if (!config.openaiApiKey) {
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: config.openaiApiKey
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
- if (!config.anthropicApiKey) {
81
- throw new Error('Anthropic API key not configured. Set ANTHROPIC_API_KEY in contents/plugins/ai/.env')
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: config.anthropicApiKey
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/plugin-ai",
3
- "version": "0.1.0-beta.126",
3
+ "version": "0.1.0-beta.128",
4
4
  "private": false,
5
5
  "main": "./plugin.config.ts",
6
6
  "requiredPlugins": [],