@tuturuuu/ai 0.16.0 → 0.17.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.16.0",
4
+ "version": "0.17.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -76,9 +76,9 @@
76
76
  "@streamdown/math": "^1.0.2",
77
77
  "@streamdown/mermaid": "^1.0.2",
78
78
  "@tuturuuu/google": "0.1.1",
79
- "@tuturuuu/internal-api": "0.42.0",
79
+ "@tuturuuu/internal-api": "0.49.0",
80
80
  "@tuturuuu/supabase": "0.5.2",
81
- "@tuturuuu/utils": "0.31.0",
81
+ "@tuturuuu/utils": "0.34.0",
82
82
  "@vercel/sandbox": "^3.3.0",
83
83
  "@zernio/chat-sdk-adapter": "^0.5.1",
84
84
  "ai": "^7.0.107",
@@ -105,7 +105,7 @@
105
105
  "zod": "^4.6.2"
106
106
  },
107
107
  "devDependencies": {
108
- "@tuturuuu/types": "0.33.0",
108
+ "@tuturuuu/types": "0.34.0",
109
109
  "@tuturuuu/typescript-config": "0.1.2",
110
110
  "@types/node": "^26.5.1",
111
111
  "@types/qrcode": "^1.5.6",
@@ -317,9 +317,13 @@ export async function processMessagesWithFiles(
317
317
  messages: ModelMessage[],
318
318
  wsId: string,
319
319
  chatId: string,
320
- request?: Pick<Request, 'headers'>
320
+ request?: Pick<Request, 'headers'>,
321
+ excludeAudioVideoFiles = false
321
322
  ): Promise<ModelMessage[]> {
322
- const chatFiles = await getAllChatFiles(wsId, chatId, request);
323
+ const chatFiles = (await getAllChatFiles(wsId, chatId, request)).filter(
324
+ (file) =>
325
+ !excludeAudioVideoFiles || !/^(audio|video)\//.test(file.mediaType)
326
+ );
323
327
  if (chatFiles.length === 0) {
324
328
  return messages;
325
329
  }
@@ -0,0 +1,12 @@
1
+ export function createGoogleChatProviderOptions(includeThoughts: boolean) {
2
+ const options = {
3
+ thinkingConfig: { includeThoughts },
4
+ safetySettings: [
5
+ { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' },
6
+ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
7
+ { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
8
+ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' },
9
+ ],
10
+ };
11
+ return { google: options, vertex: options };
12
+ }
@@ -229,7 +229,10 @@ export async function prepareProcessedMessages(
229
229
  wsId: string | undefined,
230
230
  chatId: string,
231
231
  request?: Pick<Request, 'headers'>,
232
- options?: { attachYoutubeVideoInput?: boolean }
232
+ options?: {
233
+ attachYoutubeVideoInput?: boolean;
234
+ excludeAudioVideoFiles?: boolean;
235
+ }
233
236
  ): Promise<{ processedMessages: ModelMessage[] } | { error: Response }> {
234
237
  const modelMessages = await convertToModelMessages(normalizedMessages);
235
238
  const validationError = validateModelMessages(modelMessages);
@@ -239,7 +242,13 @@ export async function prepareProcessedMessages(
239
242
 
240
243
  const processedMessages =
241
244
  wsId && chatId
242
- ? await processMessagesWithFiles(modelMessages, wsId, chatId, request)
245
+ ? await processMessagesWithFiles(
246
+ modelMessages,
247
+ wsId,
248
+ chatId,
249
+ request,
250
+ options?.excludeAudioVideoFiles
251
+ )
243
252
  : modelMessages;
244
253
 
245
254
  return {
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /** A subscription model can never enter gateway allocation or credit billing. */
4
+ export function validateSubscriptionRoute(
5
+ model: string | undefined,
6
+ subscription: boolean
7
+ ) {
8
+ if (!subscription && model?.trim().toLowerCase().startsWith('chatgpt/')) {
9
+ return NextResponse.json(
10
+ {
11
+ error: 'Use the ChatGPT subscription endpoint for this model',
12
+ code: 'CHATGPT_ROUTE_REQUIRED',
13
+ },
14
+ { status: 400 }
15
+ );
16
+ }
17
+ }
@@ -4,7 +4,13 @@ import {
4
4
  normalizeWorkspaceId,
5
5
  verifyWorkspaceMembershipType,
6
6
  } from '@tuturuuu/utils/workspace-helper';
7
- import { consumeStream, smoothStream, stepCountIs, streamText } from 'ai';
7
+ import {
8
+ consumeStream,
9
+ type LanguageModel,
10
+ smoothStream,
11
+ stepCountIs,
12
+ streamText,
13
+ } from 'ai';
8
14
  import { type NextRequest, NextResponse } from 'next/server';
9
15
  import { normalizeStableModelId } from '../../credits/model-mapping';
10
16
  import {
@@ -26,6 +32,7 @@ import { ChatRequestBodySchema, mapToUIMessages } from './chat-request-schema';
26
32
  import { resolveChatTools } from './chat-tools';
27
33
  import { systemInstruction } from './default-system-instruction';
28
34
  import { prepareMiraToolStep } from './mira-step-preparation';
35
+ import { createGoogleChatProviderOptions } from './provider-options';
29
36
  import { resolveChatReasoningSettings } from './reasoning-settings';
30
37
  import {
31
38
  beginAiPersistenceRequest,
@@ -52,6 +59,7 @@ import {
52
59
  splitSystemMessages,
53
60
  } from './route-message-preparation';
54
61
  import { prepareMiraRuntime } from './route-mira-runtime';
62
+ import { validateSubscriptionRoute } from './route-subscription-boundary';
55
63
  import {
56
64
  buildAbortedStreamFinishResponse,
57
65
  persistAssistantResponse,
@@ -60,6 +68,19 @@ import {
60
68
  export function createPOST(
61
69
  _options: {
62
70
  serverAPIKeyFallback?: boolean;
71
+ /** Direct user-funded chat. It bypasses gateway memory, credits, and provider tools. */
72
+ subscription?: {
73
+ resolveModel: (
74
+ userId: string,
75
+ model: string | undefined
76
+ ) => Promise<LanguageModel>;
77
+ onError: () => string;
78
+ errorResponse?: (error: unknown) => {
79
+ message: string;
80
+ status: number;
81
+ code: string;
82
+ };
83
+ };
63
84
  /** Gateway provider prefix for bare model names (e.g., 'openai', 'anthropic', 'vertex'). Defaults to 'google'. */
64
85
  defaultProvider?: string;
65
86
  requireWorkspaceId?: boolean;
@@ -104,7 +125,7 @@ export function createPOST(
104
125
  messages,
105
126
  wsId,
106
127
  workspaceContextId,
107
- isMiraMode,
128
+ isMiraMode: requestedMiraMode,
108
129
  timezone,
109
130
  thinkingMode: rawThinkingMode,
110
131
  creditSource: requestedCreditSourceRaw,
@@ -113,6 +134,12 @@ export function createPOST(
113
134
  persistenceRequestId,
114
135
  taskBoardContext,
115
136
  } = parsedBody.data;
137
+ const isMiraMode = _options.subscription ? false : requestedMiraMode;
138
+ const subscriptionError = validateSubscriptionRoute(
139
+ model,
140
+ !!_options.subscription
141
+ );
142
+ if (subscriptionError) return subscriptionError;
116
143
  const thinkingMode = rawThinkingMode === 'thinking' ? 'thinking' : 'fast';
117
144
 
118
145
  if (!messages) {
@@ -124,6 +151,24 @@ export function createPOST(
124
151
  (await _options.resolveAuth?.(req)) ?? (await resolveAiRouteAuth(req));
125
152
  if (!auth.ok) return auth.response;
126
153
  const { messageInsertMode = 'rpc', supabase, user } = auth;
154
+ let subscriptionModel: LanguageModel | undefined;
155
+ if (_options.subscription) {
156
+ try {
157
+ subscriptionModel = await _options.subscription.resolveModel(
158
+ user.id,
159
+ model
160
+ );
161
+ } catch (error) {
162
+ const failure = _options.subscription.errorResponse?.(error);
163
+ return NextResponse.json(
164
+ {
165
+ error: failure?.message ?? _options.subscription.onError(),
166
+ code: failure?.code ?? 'CHATGPT_CONNECTION_REQUIRED',
167
+ },
168
+ { status: failure?.status ?? 403 }
169
+ );
170
+ }
171
+ }
127
172
 
128
173
  if (isMiraMode && !(await isInternalTuturuuuAiUser(auth))) {
129
174
  return NextResponse.json(
@@ -174,7 +219,7 @@ export function createPOST(
174
219
  requestedCreditSourceRaw ?? 'workspace';
175
220
  let billingWsId: string | null = normalizedWsId ?? null;
176
221
 
177
- if (requestedCreditSource === 'personal') {
222
+ if (!_options.subscription && requestedCreditSource === 'personal') {
178
223
  const { data: personalWorkspace, error: personalWorkspaceError } =
179
224
  await sbAdmin
180
225
  .from('workspaces')
@@ -220,7 +265,7 @@ export function createPOST(
220
265
  }
221
266
 
222
267
  billingWsId = personalWorkspace.id;
223
- } else if (requestedCreditWsId) {
268
+ } else if (!_options.subscription && requestedCreditWsId) {
224
269
  if (normalizedWsId && requestedCreditWsId !== normalizedWsId) {
225
270
  return NextResponse.json(
226
271
  {
@@ -263,7 +308,9 @@ export function createPOST(
263
308
 
264
309
  let resolvedModelId: string;
265
310
  try {
266
- if (billingWsId) {
311
+ if (subscriptionModel && model) {
312
+ resolvedModelId = model;
313
+ } else if (billingWsId) {
267
314
  const resolvedPlanModel = await resolvePlanModel({
268
315
  capability: 'language',
269
316
  requestedModel: model,
@@ -346,6 +393,7 @@ export function createPOST(
346
393
  chatId,
347
394
  req,
348
395
  {
396
+ excludeAudioVideoFiles: !!subscriptionModel,
349
397
  attachYoutubeVideoInput:
350
398
  resolvedModelId.toLowerCase().startsWith('google/') ||
351
399
  resolvedModelId.toLowerCase().startsWith('google-vertex/'),
@@ -355,6 +403,26 @@ export function createPOST(
355
403
  return preparedMessages.error;
356
404
  }
357
405
  const { processedMessages } = preparedMessages;
406
+ if (
407
+ subscriptionModel &&
408
+ processedMessages.some(
409
+ (message) =>
410
+ Array.isArray(message.content) &&
411
+ message.content.some(
412
+ (part) =>
413
+ part.type === 'file' && /^(audio|video)\//.test(part.mediaType)
414
+ )
415
+ )
416
+ ) {
417
+ return NextResponse.json(
418
+ {
419
+ error:
420
+ 'ChatGPT plan sharing does not support audio or video inputs',
421
+ },
422
+ { status: 400 }
423
+ );
424
+ }
425
+
358
426
  const promptMessages = splitSystemMessages(processedMessages);
359
427
 
360
428
  const persistUserMessageError = await persistLatestUserMessage({
@@ -420,12 +488,14 @@ export function createPOST(
420
488
  return persistUserMessageError;
421
489
  }
422
490
 
423
- const creditPreflight = await performCreditPreflight({
424
- wsId: billingWsId ?? normalizedWsId ?? undefined,
425
- model: resolvedModelId,
426
- userId: user.id,
427
- sbAdmin,
428
- });
491
+ const creditPreflight = subscriptionModel
492
+ ? { cappedMaxOutput: null }
493
+ : await performCreditPreflight({
494
+ wsId: billingWsId ?? normalizedWsId ?? undefined,
495
+ model: resolvedModelId,
496
+ userId: user.id,
497
+ sbAdmin,
498
+ });
429
499
  if ('error' in creditPreflight) {
430
500
  return creditPreflight.error;
431
501
  }
@@ -452,15 +522,17 @@ export function createPOST(
452
522
 
453
523
  const effectiveSource = isMiraMode ? 'Mira' : 'Rewise';
454
524
 
455
- const resolvedGatewayModel = await withAiMemory({
456
- customId: chatId,
457
- model: google(resolvedModelId.split('/').slice(-1)[0]!),
458
- product: isMiraMode ? 'mira' : 'rewise',
459
- source: effectiveSource,
460
- surface: 'shared_chat',
461
- userId: user.id,
462
- wsId: billingWsId ?? normalizedWsId,
463
- });
525
+ const resolvedGatewayModel =
526
+ subscriptionModel ??
527
+ (await withAiMemory({
528
+ customId: chatId,
529
+ model: google(resolvedModelId.split('/').slice(-1)[0]!),
530
+ product: isMiraMode ? 'mira' : 'rewise',
531
+ source: effectiveSource,
532
+ surface: 'shared_chat',
533
+ userId: user.id,
534
+ wsId: billingWsId ?? normalizedWsId,
535
+ }));
464
536
 
465
537
  const reasoningSettings = resolveChatReasoningSettings(thinkingMode);
466
538
  const forceWorkspaceArtifact = shouldPresentWorkspaceArtifact(
@@ -540,6 +612,7 @@ export function createPOST(
540
612
  effectiveSource,
541
613
  wsId: billingWsId ?? normalizedWsId ?? undefined,
542
614
  observabilityContext,
615
+ skipCreditDeduction: !!subscriptionModel,
543
616
  persistenceRequestId,
544
617
  }),
545
618
  });
@@ -556,7 +629,7 @@ export function createPOST(
556
629
  promptMessages.system
557
630
  ),
558
631
  ...(cappedMaxOutput ? { maxOutputTokens: cappedMaxOutput } : {}),
559
- tools: resolveChatTools(miraTools),
632
+ tools: subscriptionModel ? undefined : resolveChatTools(miraTools),
560
633
  ...(miraTools
561
634
  ? {
562
635
  stopWhen: stepCountIs(25),
@@ -566,54 +639,9 @@ export function createPOST(
566
639
  >,
567
640
  }
568
641
  : {}),
569
- providerOptions: {
570
- google: {
571
- thinkingConfig: {
572
- includeThoughts: reasoningSettings.includeThoughts,
573
- },
574
- safetySettings: [
575
- {
576
- category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
577
- threshold: 'BLOCK_NONE',
578
- },
579
- {
580
- category: 'HARM_CATEGORY_HATE_SPEECH',
581
- threshold: 'BLOCK_NONE',
582
- },
583
- {
584
- category: 'HARM_CATEGORY_HARASSMENT',
585
- threshold: 'BLOCK_NONE',
586
- },
587
- {
588
- category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
589
- threshold: 'BLOCK_NONE',
590
- },
591
- ],
592
- },
593
- vertex: {
594
- thinkingConfig: {
595
- includeThoughts: reasoningSettings.includeThoughts,
596
- },
597
- safetySettings: [
598
- {
599
- category: 'HARM_CATEGORY_HARASSMENT',
600
- threshold: 'BLOCK_NONE',
601
- },
602
- {
603
- category: 'HARM_CATEGORY_HATE_SPEECH',
604
- threshold: 'BLOCK_NONE',
605
- },
606
- {
607
- category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
608
- threshold: 'BLOCK_NONE',
609
- },
610
- {
611
- category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
612
- threshold: 'BLOCK_NONE',
613
- },
614
- ],
615
- },
616
- },
642
+ providerOptions: subscriptionModel
643
+ ? undefined
644
+ : createGoogleChatProviderOptions(reasoningSettings.includeThoughts),
617
645
  onAbort: async ({ steps }) =>
618
646
  persistChatAssistantResponse(buildAbortedStreamFinishResponse(steps)),
619
647
  onFinish: persistChatAssistantResponse,
@@ -631,6 +659,9 @@ export function createPOST(
631
659
  consumeSseStream: consumeStream,
632
660
  sendReasoning: true,
633
661
  sendSources: true,
662
+ ...(_options.subscription
663
+ ? { onError: _options.subscription.onError }
664
+ : {}),
634
665
  });
635
666
  } catch (error) {
636
667
  if (releaseClaimedPersistenceLease) {
@@ -642,6 +673,15 @@ export function createPOST(
642
673
  });
643
674
  }
644
675
  }
676
+ if (_options.subscription) {
677
+ console.error('ChatGPT request failed', {
678
+ errorType: error instanceof Error ? error.name : 'UnknownError',
679
+ });
680
+ return NextResponse.json(
681
+ { error: _options.subscription.onError() },
682
+ { status: 502 }
683
+ );
684
+ }
645
685
  if (error instanceof Error) {
646
686
  console.log(error.message);
647
687
  return NextResponse.json(
@@ -109,6 +109,7 @@ type PersistAssistantResponseParams = {
109
109
  observabilityContext?: unknown;
110
110
  persistenceRequestId?: string;
111
111
  wsId?: string;
112
+ skipCreditDeduction?: boolean;
112
113
  };
113
114
 
114
115
  function collectToolData(steps: StepLike[]) {
@@ -469,7 +470,9 @@ export async function persistAssistantResponse({
469
470
  observabilityContext,
470
471
  persistenceRequestId,
471
472
  wsId,
473
+ skipCreditDeduction = false,
472
474
  }: PersistAssistantResponseParams): Promise<boolean> {
475
+ if (skipCreditDeduction && response.finishReason !== 'stop') return false;
473
476
  const steps = response.steps ?? [];
474
477
  const { allToolCalls, allToolResults } = collectToolData(steps);
475
478
 
@@ -517,10 +520,9 @@ export async function persistAssistantResponse({
517
520
  .map((part) => part.text)
518
521
  .join('\n\n'),
519
522
  role: 'ASSISTANT',
520
- model: (model.includes('/')
521
- ? model.split('/').pop()!
522
- : model
523
- ).toLowerCase(),
523
+ model: skipCreditDeduction
524
+ ? model
525
+ : (model.includes('/') ? model.split('/').pop()! : model).toLowerCase(),
524
526
  finish_reason: response.finishReason,
525
527
  prompt_tokens: inputTokens,
526
528
  completion_tokens: outputTokens,
@@ -563,6 +565,7 @@ export async function persistAssistantResponse({
563
565
  }
564
566
 
565
567
  if (
568
+ !skipCreditDeduction &&
566
569
  (wsId || userId) &&
567
570
  (inputTokens > 0 ||
568
571
  outputTokens > 0 ||
package/src/openai.ts ADDED
@@ -0,0 +1 @@
1
+ export { createOpenAI } from '@ai-sdk/openai';
@@ -83,6 +83,12 @@ export function searchMiraTools(
83
83
  .some((word) => word === term || term === `${word}s`)
84
84
  )
85
85
  );
86
+ const creatingTasks =
87
+ /\b(?:add|create|make)\b.*\btasks?\b|\bnew tasks?\b/.test(query) &&
88
+ !/\b(?:label|assignee|project)\b|\btasks?\s+lists?\b/.test(query);
89
+ const taskCreationPrerequisiteScore: Record<string, number> = creatingTasks
90
+ ? { create_task: 30, list_boards: 28, list_task_lists: 26 }
91
+ : {};
86
92
  const matches = (
87
93
  Object.entries(MIRA_TOOL_DIRECTORY) as [MiraToolName, string][]
88
94
  )
@@ -99,6 +105,7 @@ export function searchMiraTools(
99
105
  const nameWords = normalize(name).split(' ');
100
106
  const score =
101
107
  (normalize(name) === query ? 100 : 0) +
108
+ (taskCreationPrerequisiteScore[name] ?? 0) +
102
109
  terms.reduce(
103
110
  (total, term) =>
104
111
  total +