@tuturuuu/ai 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/ai",
3
3
  "license": "MIT",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -22,53 +22,53 @@
22
22
  "type-check": "tsc --project tsconfig.typecheck.json"
23
23
  },
24
24
  "dependencies": {
25
- "@ai-sdk/amazon-bedrock": "^5.0.29",
26
- "@ai-sdk/anthropic": "^4.0.18",
27
- "@ai-sdk/azure": "^4.0.20",
28
- "@ai-sdk/cerebras": "^3.0.14",
29
- "@ai-sdk/cohere": "^4.0.12",
30
- "@ai-sdk/deepgram": "^3.0.12",
31
- "@ai-sdk/deepinfra": "^3.0.14",
32
- "@ai-sdk/deepseek": "^3.0.13",
33
- "@ai-sdk/elevenlabs": "^3.0.13",
34
- "@ai-sdk/fal": "^3.0.13",
35
- "@ai-sdk/fireworks": "^3.0.15",
36
- "@ai-sdk/gateway": "^4.0.27",
37
- "@ai-sdk/gladia": "^3.0.12",
38
- "@ai-sdk/google": "^4.0.23",
39
- "@ai-sdk/google-vertex": "^5.0.27",
40
- "@ai-sdk/groq": "^4.0.13",
41
- "@ai-sdk/hume": "^3.0.12",
42
- "@ai-sdk/lmnt": "^3.0.12",
43
- "@ai-sdk/luma": "^3.0.13",
44
- "@ai-sdk/mistral": "^4.0.14",
45
- "@ai-sdk/openai": "^4.0.19",
46
- "@ai-sdk/openai-compatible": "^3.0.14",
47
- "@ai-sdk/perplexity": "^4.0.13",
48
- "@ai-sdk/react": "^4.0.39",
49
- "@ai-sdk/replicate": "^3.0.13",
50
- "@ai-sdk/revai": "^3.0.12",
51
- "@ai-sdk/togetherai": "^3.0.15",
52
- "@ai-sdk/xai": "^4.0.18",
25
+ "@ai-sdk/amazon-bedrock": "^5.0.42",
26
+ "@ai-sdk/anthropic": "^4.0.29",
27
+ "@ai-sdk/azure": "^4.0.30",
28
+ "@ai-sdk/cerebras": "^3.0.22",
29
+ "@ai-sdk/cohere": "^4.0.20",
30
+ "@ai-sdk/deepgram": "^3.0.20",
31
+ "@ai-sdk/deepinfra": "^3.0.22",
32
+ "@ai-sdk/deepseek": "^3.0.21",
33
+ "@ai-sdk/elevenlabs": "^3.0.21",
34
+ "@ai-sdk/fal": "^3.0.21",
35
+ "@ai-sdk/fireworks": "^3.0.24",
36
+ "@ai-sdk/gateway": "^4.0.40",
37
+ "@ai-sdk/gladia": "^3.0.20",
38
+ "@ai-sdk/google": "^4.0.33",
39
+ "@ai-sdk/google-vertex": "^5.0.40",
40
+ "@ai-sdk/groq": "^4.0.21",
41
+ "@ai-sdk/hume": "^3.0.20",
42
+ "@ai-sdk/lmnt": "^3.0.20",
43
+ "@ai-sdk/luma": "^3.0.21",
44
+ "@ai-sdk/mistral": "^4.0.22",
45
+ "@ai-sdk/openai": "^4.0.29",
46
+ "@ai-sdk/openai-compatible": "^3.0.22",
47
+ "@ai-sdk/perplexity": "^4.0.22",
48
+ "@ai-sdk/react": "^4.0.54",
49
+ "@ai-sdk/replicate": "^3.0.21",
50
+ "@ai-sdk/revai": "^3.0.20",
51
+ "@ai-sdk/togetherai": "^3.0.23",
52
+ "@ai-sdk/xai": "^4.0.27",
53
53
  "@beeper/chat-adapter-matrix": "^0.2.0",
54
54
  "@bitbasti/chat-adapter-webex": "^0.1.0",
55
- "@chat-adapter/discord": "^4.34.0",
56
- "@chat-adapter/gchat": "^4.34.0",
57
- "@chat-adapter/github": "^4.34.0",
58
- "@chat-adapter/linear": "^4.34.0",
59
- "@chat-adapter/messenger": "^4.34.0",
60
- "@chat-adapter/slack": "^4.34.0",
61
- "@chat-adapter/state-ioredis": "^4.34.0",
62
- "@chat-adapter/state-memory": "^4.34.0",
63
- "@chat-adapter/state-pg": "^4.34.0",
64
- "@chat-adapter/state-redis": "^4.34.0",
65
- "@chat-adapter/teams": "^4.34.0",
66
- "@chat-adapter/telegram": "^4.34.0",
67
- "@chat-adapter/web": "^4.34.0",
68
- "@chat-adapter/whatsapp": "^4.34.0",
55
+ "@chat-adapter/discord": "^4.36.0",
56
+ "@chat-adapter/gchat": "^4.36.0",
57
+ "@chat-adapter/github": "^4.36.0",
58
+ "@chat-adapter/linear": "^4.36.0",
59
+ "@chat-adapter/messenger": "^4.36.0",
60
+ "@chat-adapter/slack": "^4.36.0",
61
+ "@chat-adapter/state-ioredis": "^4.36.0",
62
+ "@chat-adapter/state-memory": "^4.36.0",
63
+ "@chat-adapter/state-pg": "^4.36.0",
64
+ "@chat-adapter/state-redis": "^4.36.0",
65
+ "@chat-adapter/teams": "^4.36.0",
66
+ "@chat-adapter/telegram": "^4.36.0",
67
+ "@chat-adapter/web": "^4.36.0",
68
+ "@chat-adapter/whatsapp": "^4.36.0",
69
69
  "@json-render/core": "^0.19.0",
70
70
  "@json-render/react": "^0.19.0",
71
- "@liveblocks/chat-sdk-adapter": "^3.22.0",
71
+ "@liveblocks/chat-sdk-adapter": "^3.23.0",
72
72
  "@octokit/rest": "^22.0.1",
73
73
  "@resend/chat-sdk-adapter": "^0.2.2",
74
74
  "@streamdown/cjk": "^1.0.3",
@@ -76,14 +76,14 @@
76
76
  "@streamdown/math": "^1.0.2",
77
77
  "@streamdown/mermaid": "^1.0.2",
78
78
  "@tuturuuu/google": "0.1.0",
79
- "@tuturuuu/internal-api": "0.24.0",
79
+ "@tuturuuu/internal-api": "0.28.0",
80
80
  "@tuturuuu/supabase": "0.5.0",
81
- "@tuturuuu/utils": "0.20.0",
82
- "@vercel/sandbox": "^2.8.0",
83
- "@zernio/chat-sdk-adapter": "^0.4.0",
84
- "ai": "^7.0.36",
81
+ "@tuturuuu/utils": "0.22.0",
82
+ "@vercel/sandbox": "^2.9.2",
83
+ "@zernio/chat-sdk-adapter": "^0.5.0",
84
+ "ai": "^7.0.51",
85
85
  "bash-tool": "^1.3.18",
86
- "chat": "^4.34.0",
86
+ "chat": "^4.36.0",
87
87
  "chat-adapter-baileys": "^2.1.0",
88
88
  "chat-adapter-blooio": "^0.1.0",
89
89
  "chat-adapter-imessage": "^1.1.0",
@@ -93,7 +93,7 @@
93
93
  "chat-state-cloudflare-do": "^0.2.0",
94
94
  "chat-state-mysql": "^0.1.0",
95
95
  "dayjs": "^1.11.21",
96
- "next": "^16.3.0-preview.9",
96
+ "next": "^16.3.0",
97
97
  "qrcode": "^1.5.4",
98
98
  "react": "^19.2.8",
99
99
  "react-dom": "^19.2.8",
@@ -103,11 +103,11 @@
103
103
  "zod": "^4.4.3"
104
104
  },
105
105
  "devDependencies": {
106
- "@tuturuuu/types": "0.23.0",
106
+ "@tuturuuu/types": "0.27.0",
107
107
  "@tuturuuu/typescript-config": "0.1.1",
108
- "@types/node": "^26.1.1",
108
+ "@types/node": "^26.1.2",
109
109
  "@types/qrcode": "^1.5.6",
110
- "@types/react": "^19.2.17",
110
+ "@types/react": "^19.2.18",
111
111
  "typescript": "7.0.2"
112
112
  },
113
113
  "exports": {
@@ -56,6 +56,7 @@ export const ChatRequestBodySchema = z.object({
56
56
  creditSource: z.enum(['personal', 'workspace']).optional(),
57
57
  creditWsId: z.string().trim().min(1).max(MAX_ID_LENGTH).optional(),
58
58
  observabilityContext: z.array(z.record(z.string(), z.unknown())).optional(),
59
+ persistenceRequestId: z.string().trim().uuid().optional(),
59
60
  taskBoardContext: TaskBoardContextSchema.optional(),
60
61
  });
61
62
 
@@ -0,0 +1,218 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ type RpcError = { message: string };
4
+
5
+ type PrivateRpcClient = {
6
+ schema: (schema: 'private') => {
7
+ rpc: (
8
+ name: string,
9
+ args: Record<string, unknown>
10
+ ) => PromiseLike<{ data: unknown; error: RpcError | null }>;
11
+ };
12
+ };
13
+
14
+ type PersistenceSource = 'Mira' | 'Rewise';
15
+
16
+ export type PersistenceLease = {
17
+ leaseToken: string;
18
+ retryAfterSeconds: number;
19
+ state: 'active' | 'claimed' | 'completed';
20
+ };
21
+
22
+ export type ClaimedPersistenceLease = Pick<PersistenceLease, 'leaseToken'> & {
23
+ chatId: string;
24
+ requestId: string;
25
+ };
26
+
27
+ function parseClaimResult(data: unknown, leaseToken: string): PersistenceLease {
28
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
29
+ throw new Error('Invalid AI persistence lease response');
30
+ }
31
+
32
+ const record = data as Record<string, unknown>;
33
+ if (!['active', 'claimed', 'completed'].includes(String(record.state))) {
34
+ throw new Error('Invalid AI persistence lease state');
35
+ }
36
+
37
+ const retryAfterSeconds = Number(record.retryAfterSeconds);
38
+ return {
39
+ leaseToken,
40
+ retryAfterSeconds:
41
+ Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
42
+ ? Math.ceil(retryAfterSeconds)
43
+ : 0,
44
+ state: record.state as PersistenceLease['state'],
45
+ };
46
+ }
47
+
48
+ export async function claimAiPersistenceRequest({
49
+ chatId,
50
+ client,
51
+ content,
52
+ creatorId,
53
+ requestId,
54
+ source,
55
+ }: {
56
+ chatId: string;
57
+ client: unknown;
58
+ content: string;
59
+ creatorId: string;
60
+ requestId: string;
61
+ source: PersistenceSource;
62
+ }): Promise<PersistenceLease> {
63
+ const leaseToken = crypto.randomUUID();
64
+ const { data, error } = await (client as PrivateRpcClient)
65
+ .schema('private')
66
+ .rpc('ai_chat_claim_persistence_request', {
67
+ p_chat_id: chatId,
68
+ p_content: content,
69
+ p_creator_id: creatorId,
70
+ p_lease_token: leaseToken,
71
+ p_request_id: requestId,
72
+ p_source: source,
73
+ });
74
+
75
+ if (error) throw new Error(error.message);
76
+ return parseClaimResult(data, leaseToken);
77
+ }
78
+
79
+ export async function beginAiPersistenceRequest({
80
+ chatId,
81
+ client,
82
+ content,
83
+ creatorId,
84
+ requestId,
85
+ source,
86
+ }: {
87
+ chatId: string;
88
+ client: unknown;
89
+ content: string;
90
+ creatorId: string;
91
+ requestId?: string;
92
+ source: PersistenceSource;
93
+ }): Promise<
94
+ | { lease: ClaimedPersistenceLease; response: null }
95
+ | { lease: null; response: Response | null }
96
+ > {
97
+ if (!requestId) return { lease: null, response: null };
98
+
99
+ const result = await claimAiPersistenceRequest({
100
+ chatId,
101
+ client,
102
+ content,
103
+ creatorId,
104
+ requestId,
105
+ source,
106
+ });
107
+ if (result.state === 'active') {
108
+ return {
109
+ lease: null,
110
+ response: NextResponse.json(
111
+ {
112
+ code: 'ai_request_in_progress',
113
+ message: 'This AI request is already in progress.',
114
+ },
115
+ {
116
+ headers: {
117
+ 'Retry-After': String(Math.max(1, result.retryAfterSeconds)),
118
+ },
119
+ status: 409,
120
+ }
121
+ ),
122
+ };
123
+ }
124
+ if (result.state === 'completed') {
125
+ return {
126
+ lease: null,
127
+ response: NextResponse.json(
128
+ {
129
+ code: 'ai_request_completed',
130
+ message: 'This AI request has already completed.',
131
+ },
132
+ { status: 409 }
133
+ ),
134
+ };
135
+ }
136
+
137
+ return {
138
+ lease: { chatId, leaseToken: result.leaseToken, requestId },
139
+ response: null,
140
+ };
141
+ }
142
+
143
+ async function updateAiPersistenceRequest(
144
+ client: unknown,
145
+ rpcName:
146
+ | 'ai_chat_complete_persistence_request'
147
+ | 'ai_chat_release_persistence_request',
148
+ lease: ClaimedPersistenceLease
149
+ ): Promise<boolean> {
150
+ const { data, error } = await (client as PrivateRpcClient)
151
+ .schema('private')
152
+ .rpc(rpcName, {
153
+ p_chat_id: lease.chatId,
154
+ p_lease_token: lease.leaseToken,
155
+ p_request_id: lease.requestId,
156
+ });
157
+
158
+ if (error) throw new Error(error.message);
159
+ return data === true;
160
+ }
161
+
162
+ export function completeAiPersistenceRequest(
163
+ client: unknown,
164
+ lease: ClaimedPersistenceLease
165
+ ) {
166
+ return updateAiPersistenceRequest(
167
+ client,
168
+ 'ai_chat_complete_persistence_request',
169
+ lease
170
+ );
171
+ }
172
+
173
+ export function releaseAiPersistenceRequest(
174
+ client: unknown,
175
+ lease: ClaimedPersistenceLease
176
+ ) {
177
+ return updateAiPersistenceRequest(
178
+ client,
179
+ 'ai_chat_release_persistence_request',
180
+ lease
181
+ );
182
+ }
183
+
184
+ export function createAiPersistenceFinisher<Response>({
185
+ client,
186
+ lease,
187
+ onSettled,
188
+ persist,
189
+ }: {
190
+ client: unknown;
191
+ lease: ClaimedPersistenceLease | null;
192
+ onSettled: () => void;
193
+ persist: (response: Response) => Promise<boolean>;
194
+ }) {
195
+ let handled = false;
196
+
197
+ return async (response: Response): Promise<void> => {
198
+ if (handled) return;
199
+ handled = true;
200
+
201
+ try {
202
+ const persisted = await persist(response);
203
+ if (lease && persisted) {
204
+ const completed = await completeAiPersistenceRequest(client, lease);
205
+ if (!completed) {
206
+ throw new Error('Failed to complete AI persistence request lease');
207
+ }
208
+ } else if (lease) {
209
+ await releaseAiPersistenceRequest(client, lease);
210
+ }
211
+ onSettled();
212
+ } catch (error) {
213
+ if (lease) await releaseAiPersistenceRequest(client, lease);
214
+ onSettled();
215
+ throw error;
216
+ }
217
+ };
218
+ }
@@ -252,7 +252,7 @@ export async function prepareProcessedMessages(
252
252
  };
253
253
  }
254
254
 
255
- function extractLatestUserMessageContent(
255
+ export function extractLatestUserMessageContent(
256
256
  processedMessages: ModelMessage[]
257
257
  ): string {
258
258
  const userMessages = processedMessages.filter(
@@ -298,6 +298,54 @@ type PersistLatestUserMessageParams = {
298
298
  source: 'Mira' | 'Rewise';
299
299
  };
300
300
 
301
+ type RequestScopedPersistenceError = {
302
+ code?: string;
303
+ message: string;
304
+ };
305
+
306
+ export async function persistRequestScopedUserMessage({
307
+ chatId,
308
+ content,
309
+ creatorId,
310
+ findExistingMessage,
311
+ insertMessage,
312
+ requestId,
313
+ source,
314
+ }: {
315
+ chatId: string;
316
+ content: string;
317
+ creatorId: string;
318
+ findExistingMessage: () => PromiseLike<{
319
+ data: { content: string | null; creator_id: string | null } | null;
320
+ error: RequestScopedPersistenceError | null;
321
+ }>;
322
+ insertMessage: () => PromiseLike<{
323
+ error: RequestScopedPersistenceError | null;
324
+ }>;
325
+ requestId: string;
326
+ source: 'Mira' | 'Rewise';
327
+ }): Promise<{ error: RequestScopedPersistenceError | null }> {
328
+ const inserted = await insertMessage();
329
+ if (!inserted.error) return inserted;
330
+ if (inserted.error.code !== '23505') return inserted;
331
+
332
+ const existing = await findExistingMessage();
333
+ if (existing.error) return { error: existing.error };
334
+ if (
335
+ existing.data?.content === content &&
336
+ existing.data.creator_id === creatorId
337
+ ) {
338
+ return { error: null };
339
+ }
340
+
341
+ return {
342
+ error: {
343
+ code: '23505',
344
+ message: `Persistence request ${requestId} already belongs to another ${source} message in chat ${chatId}`,
345
+ },
346
+ };
347
+ }
348
+
301
349
  export async function persistLatestUserMessage({
302
350
  processedMessages,
303
351
  chatId,
@@ -32,6 +32,11 @@ import { ChatRequestBodySchema, mapToUIMessages } from './chat-request-schema';
32
32
  import { systemInstruction } from './default-system-instruction';
33
33
  import { prepareMiraToolStep } from './mira-step-preparation';
34
34
  import { resolveChatReasoningSettings } from './reasoning-settings';
35
+ import {
36
+ beginAiPersistenceRequest,
37
+ createAiPersistenceFinisher,
38
+ releaseAiPersistenceRequest,
39
+ } from './request-persistence-lease';
35
40
  import {
36
41
  type AiRouteAuthResult,
37
42
  isInternalTuturuuuAiUser,
@@ -43,7 +48,9 @@ import {
43
48
  } from './route-chat-resolution';
44
49
  import { performCreditPreflight } from './route-credits';
45
50
  import {
51
+ extractLatestUserMessageContent,
46
52
  persistLatestUserMessage,
53
+ persistRequestScopedUserMessage,
47
54
  prepareProcessedMessages,
48
55
  } from './route-message-preparation';
49
56
  import { prepareMiraRuntime } from './route-mira-runtime';
@@ -102,6 +109,7 @@ export function createPOST(
102
109
 
103
110
  // Higher-order function that returns the actual request handler
104
111
  return async function handler(req: NextRequest): Promise<Response> {
112
+ let releaseClaimedPersistenceLease: (() => Promise<void>) | null = null;
105
113
  try {
106
114
  const sbAdmin = await createAdminClient();
107
115
  let requestBody: unknown;
@@ -141,6 +149,7 @@ export function createPOST(
141
149
  creditSource: requestedCreditSourceRaw,
142
150
  creditWsId: rawCreditWsId,
143
151
  observabilityContext,
152
+ persistenceRequestId,
144
153
  taskBoardContext,
145
154
  } = parsedBody.data;
146
155
  const thinkingMode = rawThinkingMode === 'thinking' ? 'thinking' : 'fast';
@@ -404,6 +413,43 @@ export function createPOST(
404
413
  processedMessages,
405
414
  chatId,
406
415
  insertChatMessage: async (args) => {
416
+ if (persistenceRequestId) {
417
+ return persistRequestScopedUserMessage({
418
+ chatId,
419
+ content: args.message,
420
+ creatorId: user.id,
421
+ findExistingMessage: async () => {
422
+ const { data, error } = await sbAdmin
423
+ .from('ai_chat_messages')
424
+ .select('content,creator_id')
425
+ .eq('chat_id', chatId)
426
+ .eq('role', 'USER')
427
+ .contains('metadata', {
428
+ requestId: persistenceRequestId,
429
+ source: args.source,
430
+ })
431
+ .maybeSingle();
432
+ return { data, error };
433
+ },
434
+ insertMessage: async () => {
435
+ const { error } = await sbAdmin
436
+ .from('ai_chat_messages')
437
+ .insert({
438
+ chat_id: chatId,
439
+ content: args.message,
440
+ creator_id: user.id,
441
+ metadata: {
442
+ requestId: persistenceRequestId,
443
+ source: args.source,
444
+ },
445
+ role: 'USER',
446
+ });
447
+ return { error };
448
+ },
449
+ requestId: persistenceRequestId,
450
+ source: args.source,
451
+ });
452
+ }
407
453
  const { error } = await supabase.rpc(
408
454
  'insert_ai_chat_message' as never,
409
455
  args as never
@@ -427,8 +473,6 @@ export function createPOST(
427
473
  }
428
474
  const { cappedMaxOutput } = creditPreflight;
429
475
 
430
- // Mutable ref so the render_ui preprocessor can read current steps
431
- // at Zod-validation time (before the execute handler runs).
432
476
  const stepsRef: { current: unknown[] } = { current: [] };
433
477
 
434
478
  const { miraSystemPrompt, miraTools } = await prepareMiraRuntime({
@@ -480,15 +524,12 @@ export function createPOST(
480
524
  promptMessages.messages
481
525
  );
482
526
 
483
- // Provider-native Google Search tool for non-Mira mode.
484
527
  const googleSearchTool = createGoogleSearchToolSet();
485
528
 
486
529
  type PrepareStep = NonNullable<
487
530
  NonNullable<Parameters<typeof streamText>[0]>['prepareStep']
488
531
  >;
489
532
  const prepareStep: PrepareStep = ({ steps }) => {
490
- // Keep the mutable ref in sync so the render_ui preprocessor can
491
- // read current steps during Zod validation.
492
533
  stepsRef.current = steps;
493
534
  return prepareMiraToolStep({
494
535
  steps,
@@ -501,23 +542,43 @@ export function createPOST(
501
542
  });
502
543
  };
503
544
 
504
- let assistantResponsePersisted = false;
505
- const persistChatAssistantResponse = async (
506
- response: Parameters<typeof persistAssistantResponse>[0]['response']
507
- ) => {
508
- if (assistantResponsePersisted) return;
509
- assistantResponsePersisted = true;
510
- await persistAssistantResponse({
511
- response,
512
- sbAdmin,
545
+ const { lease: claimedLease, response: leaseResponse } =
546
+ await beginAiPersistenceRequest({
513
547
  chatId,
514
- userId: user.id,
515
- model: resolvedModelId,
516
- effectiveSource,
517
- wsId: billingWsId ?? normalizedWsId ?? undefined,
518
- observabilityContext,
548
+ client: sbAdmin,
549
+ content: extractLatestUserMessageContent(processedMessages),
550
+ creatorId: user.id,
551
+ requestId: persistenceRequestId,
552
+ source: effectiveSource,
519
553
  });
520
- };
554
+ if (leaseResponse) return leaseResponse;
555
+ if (claimedLease) {
556
+ releaseClaimedPersistenceLease = async () => {
557
+ await releaseAiPersistenceRequest(sbAdmin, claimedLease);
558
+ };
559
+ }
560
+
561
+ const persistChatAssistantResponse = createAiPersistenceFinisher<
562
+ Parameters<typeof persistAssistantResponse>[0]['response']
563
+ >({
564
+ client: sbAdmin,
565
+ lease: claimedLease,
566
+ onSettled: () => {
567
+ releaseClaimedPersistenceLease = null;
568
+ },
569
+ persist: (response) =>
570
+ persistAssistantResponse({
571
+ response,
572
+ sbAdmin,
573
+ chatId,
574
+ userId: user.id,
575
+ model: resolvedModelId,
576
+ effectiveSource,
577
+ wsId: billingWsId ?? normalizedWsId ?? undefined,
578
+ observabilityContext,
579
+ persistenceRequestId,
580
+ }),
581
+ });
521
582
 
522
583
  const result = streamText({
523
584
  abortSignal: req.signal,
@@ -612,6 +673,15 @@ export function createPOST(
612
673
  sendSources: true,
613
674
  });
614
675
  } catch (error) {
676
+ if (releaseClaimedPersistenceLease) {
677
+ try {
678
+ await releaseClaimedPersistenceLease();
679
+ } catch (releaseError) {
680
+ console.error('Failed to release AI persistence request lease.', {
681
+ error: releaseError,
682
+ });
683
+ }
684
+ }
615
685
  if (error instanceof Error) {
616
686
  console.log(error.message);
617
687
  return NextResponse.json(
@@ -106,6 +106,7 @@ type PersistAssistantResponseParams = {
106
106
  model: string;
107
107
  effectiveSource: 'Mira' | 'Rewise';
108
108
  observabilityContext?: unknown;
109
+ persistenceRequestId?: string;
109
110
  wsId?: string;
110
111
  };
111
112
 
@@ -470,6 +471,7 @@ function buildAssistantMessageMetadata({
470
471
  inputTokens,
471
472
  model,
472
473
  observabilityContext,
474
+ persistenceRequestId,
473
475
  outputTokens,
474
476
  parts,
475
477
  reasoningText,
@@ -485,6 +487,7 @@ function buildAssistantMessageMetadata({
485
487
  inputTokens: number;
486
488
  model: string;
487
489
  observabilityContext?: unknown;
490
+ persistenceRequestId?: string;
488
491
  outputTokens: number;
489
492
  parts: Record<string, unknown>[];
490
493
  reasoningText: string;
@@ -494,6 +497,7 @@ function buildAssistantMessageMetadata({
494
497
  }) {
495
498
  return {
496
499
  source: effectiveSource,
500
+ ...(persistenceRequestId ? { requestId: persistenceRequestId } : {}),
497
501
  ai: {
498
502
  finishReason: response.finishReason,
499
503
  model,
@@ -526,6 +530,7 @@ function compactAssistantMessageMetadata(
526
530
  const ai = metadata.ai;
527
531
  return {
528
532
  source: metadata.source,
533
+ ...(metadata.requestId ? { requestId: metadata.requestId } : {}),
529
534
  ai: {
530
535
  finishReason: ai.finishReason,
531
536
  metadataCompacted: true,
@@ -568,8 +573,9 @@ export async function persistAssistantResponse({
568
573
  model,
569
574
  effectiveSource,
570
575
  observabilityContext,
576
+ persistenceRequestId,
571
577
  wsId,
572
- }: PersistAssistantResponseParams): Promise<void> {
578
+ }: PersistAssistantResponseParams): Promise<boolean> {
573
579
  const steps = response.steps ?? [];
574
580
  const { allToolCalls, allToolResults } = collectToolData(steps);
575
581
 
@@ -579,7 +585,7 @@ export async function persistAssistantResponse({
579
585
  allToolResults.length === 0
580
586
  ) {
581
587
  console.warn('onFinish: no text and no tool calls — skipping DB save');
582
- return;
588
+ return false;
583
589
  }
584
590
 
585
591
  const reasoningText = collectReasoningText(response);
@@ -608,6 +614,7 @@ export async function persistAssistantResponse({
608
614
  inputTokens,
609
615
  model,
610
616
  observabilityContext,
617
+ persistenceRequestId,
611
618
  outputTokens,
612
619
  parts,
613
620
  reasoningText,
@@ -695,7 +702,7 @@ export async function persistAssistantResponse({
695
702
  ...(searchCount > 0 ? { searchCount } : {}),
696
703
  error,
697
704
  });
698
- return;
705
+ return true;
699
706
  }
700
707
 
701
708
  if (!deductionResult.success) {
@@ -707,7 +714,7 @@ export async function persistAssistantResponse({
707
714
  ...(searchCount > 0 ? { searchCount } : {}),
708
715
  deductionResult,
709
716
  });
710
- return;
717
+ return true;
711
718
  }
712
719
 
713
720
  console.info('AI credits deducted for assistant response.', {
@@ -719,4 +726,6 @@ export async function persistAssistantResponse({
719
726
  deductionResult,
720
727
  });
721
728
  }
729
+
730
+ return true;
722
731
  }
package/src/core.ts CHANGED
@@ -1,3 +1 @@
1
1
  export * from 'ai';
2
- export * as ChatSDK from 'chat';
3
- export * as ChatSDKAI from 'chat/ai';
@@ -9,6 +9,7 @@ export const AI_FEATURES = [
9
9
  'email_draft',
10
10
  'image_generation',
11
11
  'embeddings',
12
+ 'voice_live',
12
13
  ] as const;
13
14
 
14
15
  export type AiFeature = (typeof AI_FEATURES)[number];
@@ -0,0 +1,59 @@
1
+ import { CREDIT_UNIT_USD } from '../credits/constants';
2
+
3
+ export interface GeminiLiveUsageSnapshot {
4
+ inputAudioTokens: number;
5
+ inputImageTokens: number;
6
+ inputTextTokens: number;
7
+ inputVideoTokens: number;
8
+ outputAudioTokens: number;
9
+ outputTextTokens: number;
10
+ searchQueries: number;
11
+ thinkingTokens: number;
12
+ }
13
+
14
+ export interface GeminiLivePrice {
15
+ inputAudioPerMillion: number;
16
+ inputImageVideoPerMillion: number;
17
+ inputTextPerMillion: number;
18
+ outputAudioPerMillion: number;
19
+ outputTextPerMillion: number;
20
+ searchPerQuery: number;
21
+ }
22
+
23
+ export const GEMINI_3_1_FLASH_LIVE_PRICE: GeminiLivePrice = {
24
+ inputAudioPerMillion: 3,
25
+ inputImageVideoPerMillion: 1,
26
+ inputTextPerMillion: 0.75,
27
+ outputAudioPerMillion: 12,
28
+ outputTextPerMillion: 4.5,
29
+ searchPerQuery: 0.014,
30
+ };
31
+
32
+ export function calculateGeminiLiveCost(
33
+ usage: GeminiLiveUsageSnapshot,
34
+ price: GeminiLivePrice = GEMINI_3_1_FLASH_LIVE_PRICE
35
+ ) {
36
+ return (
37
+ (usage.inputTextTokens * price.inputTextPerMillion) / 1_000_000 +
38
+ (usage.inputAudioTokens * price.inputAudioPerMillion) / 1_000_000 +
39
+ ((usage.inputImageTokens + usage.inputVideoTokens) *
40
+ price.inputImageVideoPerMillion) /
41
+ 1_000_000 +
42
+ ((usage.outputTextTokens + usage.thinkingTokens) *
43
+ price.outputTextPerMillion) /
44
+ 1_000_000 +
45
+ (usage.outputAudioTokens * price.outputAudioPerMillion) / 1_000_000 +
46
+ usage.searchQueries * price.searchPerQuery
47
+ );
48
+ }
49
+
50
+ export function calculateGeminiLiveCredits(
51
+ usage: GeminiLiveUsageSnapshot,
52
+ markupMultiplier = 1,
53
+ price: GeminiLivePrice = GEMINI_3_1_FLASH_LIVE_PRICE
54
+ ) {
55
+ const costUsd = calculateGeminiLiveCost(usage, price);
56
+ return costUsd === 0
57
+ ? 0
58
+ : Math.max(1, (costUsd / CREDIT_UNIT_USD) * markupMultiplier);
59
+ }
@@ -16,6 +16,63 @@ export type AiStudioUsageCost = {
16
16
  providerCostUsd: number;
17
17
  };
18
18
 
19
+ export type AiStudioRunStepKind = 'grader' | 'model' | 'system' | 'tool';
20
+
21
+ export type RecordAiStudioRunStepInput = {
22
+ billedCredits?: number;
23
+ completedAt?: string | null;
24
+ errorClass?: string | null;
25
+ inputTokens?: number;
26
+ kind: AiStudioRunStepKind;
27
+ latencyMs?: number | null;
28
+ metadata?: Json;
29
+ modelId?: string | null;
30
+ name: string;
31
+ outputTokens?: number;
32
+ providerCostUsd?: number;
33
+ runId: string;
34
+ sequence: number;
35
+ startedAt?: string;
36
+ status: 'aborted' | 'failed' | 'running' | 'succeeded';
37
+ };
38
+
39
+ export async function recordAiStudioRunStep(
40
+ input: RecordAiStudioRunStepInput
41
+ ): Promise<void> {
42
+ const sbAdmin = await createAdminClient({ noCookie: true });
43
+ const { error } = await sbAdmin
44
+ .schema('private')
45
+ .from('ai_studio_run_steps')
46
+ .upsert(
47
+ {
48
+ billed_credits: input.billedCredits ?? 0,
49
+ completed_at: input.completedAt ?? new Date().toISOString(),
50
+ error_class: input.errorClass ?? null,
51
+ input_tokens: input.inputTokens ?? 0,
52
+ kind: input.kind,
53
+ latency_ms: input.latencyMs ?? null,
54
+ metadata: input.metadata ?? {},
55
+ model_id: input.modelId ?? null,
56
+ name: input.name,
57
+ output_tokens: input.outputTokens ?? 0,
58
+ provider_cost_usd: input.providerCostUsd ?? 0,
59
+ run_id: input.runId,
60
+ sequence: input.sequence,
61
+ started_at: input.startedAt ?? new Date().toISOString(),
62
+ status: input.status,
63
+ },
64
+ { onConflict: 'run_id,sequence' }
65
+ );
66
+
67
+ if (error) {
68
+ console.warn('Failed to record AI Studio run step', {
69
+ code: error.code,
70
+ runId: input.runId,
71
+ sequence: input.sequence,
72
+ });
73
+ }
74
+ }
75
+
19
76
  export type CalculateAiStudioUsageCostInput = {
20
77
  imageCount?: number;
21
78
  inputTokens?: number;
@@ -131,7 +188,10 @@ export async function beginAiStudioRun(
131
188
  }
132
189
 
133
190
  export type BeginExternalAiStudioRunInput = {
134
- actorId: string;
191
+ /** Null for machine credentials, which run without a user. */
192
+ actorId: string | null;
193
+ /** Set when a bound API key authenticated the request, for rotation traceability. */
194
+ apiKeyId?: string | null;
135
195
  externalAppId: string;
136
196
  feature: string;
137
197
  idempotencyKey?: string | null;
@@ -148,13 +208,18 @@ export async function beginExternalAiStudioRun(
148
208
  const { data, error } = await sbAdmin
149
209
  .schema('private')
150
210
  .rpc('begin_external_ai_studio_run', {
211
+ p_api_key_id: input.apiKeyId ?? undefined,
151
212
  p_external_app_id: input.externalAppId,
152
213
  p_feature: input.feature,
153
214
  p_idempotency_key: input.idempotencyKey ?? undefined,
154
215
  p_metadata: input.metadata,
155
216
  p_model_id: input.modelId,
156
217
  p_request_id: input.requestId,
157
- p_user_id: input.actorId,
218
+ // Explicit null, never undefined: p_user_id has no default, so omitting it
219
+ // makes PostgREST fail to resolve the function. A machine credential
220
+ // genuinely has no user, and the column accepts NULL — the generated RPC
221
+ // types just cannot express a nullable argument.
222
+ p_user_id: input.actorId as unknown as string,
158
223
  p_ws_id: input.workspaceId,
159
224
  });
160
225
 
@@ -207,6 +272,11 @@ export async function settleAiStudioRun(
207
272
  });
208
273
 
209
274
  if (error || !data?.[0]?.success) {
275
+ console.error('AI Studio settlement RPC failed', {
276
+ code: error?.code ?? null,
277
+ errorCode: data?.[0]?.error_code ?? null,
278
+ runId: input.runId,
279
+ });
210
280
  throw new AiStudioError('AI usage could not be settled.', {
211
281
  code: 'server_error',
212
282
  status: 500,
@@ -215,8 +285,20 @@ export async function settleAiStudioRun(
215
285
  }
216
286
  }
217
287
 
288
+ export type SettleExternalAiStudioRunInput = Omit<
289
+ SettleAiStudioRunInput,
290
+ 'actualCredits'
291
+ > & {
292
+ /**
293
+ * What this run would have billed had it been metered. Recorded, not charged,
294
+ * so an app's consumption of its unmetered allocation is a reportable number
295
+ * rather than an invisible zero.
296
+ */
297
+ unmeteredCredits?: number;
298
+ };
299
+
218
300
  export async function settleExternalAiStudioRun(
219
- input: Omit<SettleAiStudioRunInput, 'actualCredits'>
301
+ input: SettleExternalAiStudioRunInput
220
302
  ): Promise<void> {
221
303
  const sbAdmin = await createAdminClient({ noCookie: true });
222
304
  const { data, error } = await sbAdmin
@@ -235,9 +317,15 @@ export async function settleExternalAiStudioRun(
235
317
  p_reasoning_tokens: input.reasoningTokens ?? 0,
236
318
  p_run_id: input.runId,
237
319
  p_status: input.status,
320
+ p_unmetered_credits: Math.max(0, input.unmeteredCredits ?? 0),
238
321
  });
239
322
 
240
323
  if (error || !data?.[0]?.success) {
324
+ console.error('External-app AI Studio settlement RPC failed', {
325
+ code: error?.code ?? null,
326
+ errorCode: data?.[0]?.error_code ?? null,
327
+ runId: input.runId,
328
+ });
241
329
  throw new AiStudioError('External-app AI usage could not be settled.', {
242
330
  code: 'server_error',
243
331
  status: 500,