@tuturuuu/ai 0.7.0 → 0.8.1

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.7.0",
4
+ "version": "0.8.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -22,34 +22,34 @@
22
22
  "type-check": "tsc --project tsconfig.typecheck.json"
23
23
  },
24
24
  "dependencies": {
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",
25
+ "@ai-sdk/amazon-bedrock": "^5.0.48",
26
+ "@ai-sdk/anthropic": "^4.0.34",
27
+ "@ai-sdk/azure": "^4.0.35",
28
+ "@ai-sdk/cerebras": "^3.0.25",
29
+ "@ai-sdk/cohere": "^4.0.23",
30
+ "@ai-sdk/deepgram": "^3.0.23",
31
+ "@ai-sdk/deepinfra": "^3.0.25",
32
+ "@ai-sdk/deepseek": "^3.0.24",
33
+ "@ai-sdk/elevenlabs": "^3.0.24",
34
+ "@ai-sdk/fal": "^3.0.24",
35
+ "@ai-sdk/fireworks": "^3.0.27",
36
+ "@ai-sdk/gateway": "^4.0.44",
37
+ "@ai-sdk/gladia": "^3.0.23",
38
+ "@ai-sdk/google": "^4.0.37",
39
+ "@ai-sdk/google-vertex": "^5.0.45",
40
+ "@ai-sdk/groq": "^4.0.24",
41
+ "@ai-sdk/hume": "^3.0.23",
42
+ "@ai-sdk/lmnt": "^3.0.23",
43
+ "@ai-sdk/luma": "^3.0.24",
44
+ "@ai-sdk/mistral": "^4.0.25",
45
+ "@ai-sdk/openai": "^4.0.34",
46
+ "@ai-sdk/openai-compatible": "^3.0.25",
47
+ "@ai-sdk/perplexity": "^4.0.25",
48
+ "@ai-sdk/react": "^4.0.59",
49
+ "@ai-sdk/replicate": "^3.0.24",
50
+ "@ai-sdk/revai": "^3.0.23",
51
+ "@ai-sdk/togetherai": "^3.0.26",
52
+ "@ai-sdk/xai": "^4.0.31",
53
53
  "@beeper/chat-adapter-matrix": "^0.2.0",
54
54
  "@bitbasti/chat-adapter-webex": "^0.1.0",
55
55
  "@chat-adapter/discord": "^4.36.0",
@@ -68,7 +68,7 @@
68
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.23.0",
71
+ "@liveblocks/chat-sdk-adapter": "^3.23.1",
72
72
  "@octokit/rest": "^22.0.1",
73
73
  "@resend/chat-sdk-adapter": "^0.2.2",
74
74
  "@streamdown/cjk": "^1.0.3",
@@ -76,12 +76,12 @@
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.27.0",
79
+ "@tuturuuu/internal-api": "0.29.0",
80
80
  "@tuturuuu/supabase": "0.5.0",
81
- "@tuturuuu/utils": "0.22.0",
81
+ "@tuturuuu/utils": "0.23.0",
82
82
  "@vercel/sandbox": "^2.9.2",
83
83
  "@zernio/chat-sdk-adapter": "^0.5.0",
84
- "ai": "^7.0.51",
84
+ "ai": "^7.0.56",
85
85
  "bash-tool": "^1.3.18",
86
86
  "chat": "^4.36.0",
87
87
  "chat-adapter-baileys": "^2.1.0",
@@ -103,7 +103,7 @@
103
103
  "zod": "^4.4.3"
104
104
  },
105
105
  "devDependencies": {
106
- "@tuturuuu/types": "0.26.0",
106
+ "@tuturuuu/types": "0.27.0",
107
107
  "@tuturuuu/typescript-config": "0.1.1",
108
108
  "@types/node": "^26.1.2",
109
109
  "@types/qrcode": "^1.5.6",
@@ -8,12 +8,119 @@ type ChatFile = {
8
8
  };
9
9
 
10
10
  const FILE_DOWNLOAD_CONCURRENCY = 4;
11
+ // Supabase storage `list` defaults to 100 rows, so a chat that accumulated more
12
+ // resources than this silently lost everything past the first page.
13
+ const STORAGE_LIST_PAGE_SIZE = 100;
14
+ const STORAGE_LIST_MAX_PAGES = 20;
15
+ // Every request re-sends the chat's whole resource set, so an unbounded chat
16
+ // would eventually build a request no provider will accept. Attachments are
17
+ // admitted oldest-first until the budget is spent.
18
+ const MAX_INJECTED_ATTACHMENT_BYTES = 32 * 1024 * 1024;
19
+
20
+ // Storage only records a media type when the uploader knew one. Without this,
21
+ // an image whose content type was lost on the way in reads as
22
+ // `application/octet-stream`, misses the `image/` check, and reaches the model
23
+ // as "this format cannot be passed directly" — invisible, with no error.
24
+ const MEDIA_TYPE_BY_EXTENSION: Record<string, string> = {
25
+ aac: 'audio/aac',
26
+ avif: 'image/avif',
27
+ csv: 'text/csv',
28
+ flac: 'audio/flac',
29
+ gif: 'image/gif',
30
+ heic: 'image/heic',
31
+ jpeg: 'image/jpeg',
32
+ jpg: 'image/jpeg',
33
+ json: 'application/json',
34
+ m4a: 'audio/mp4',
35
+ md: 'text/markdown',
36
+ mov: 'video/quicktime',
37
+ mp3: 'audio/mpeg',
38
+ mp4: 'video/mp4',
39
+ ogg: 'audio/ogg',
40
+ opus: 'audio/ogg',
41
+ pdf: 'application/pdf',
42
+ png: 'image/png',
43
+ txt: 'text/plain',
44
+ wav: 'audio/wav',
45
+ webm: 'video/webm',
46
+ webp: 'image/webp',
47
+ };
48
+
49
+ const UNKNOWN_MEDIA_TYPE = 'application/octet-stream';
50
+
51
+ export function resolveAttachmentMediaType(
52
+ fileName: string,
53
+ metadataMediaType?: string | null
54
+ ): string {
55
+ if (metadataMediaType && metadataMediaType !== UNKNOWN_MEDIA_TYPE) {
56
+ return metadataMediaType;
57
+ }
58
+
59
+ const extension = fileName.split('.').pop()?.toLowerCase();
60
+ if (extension && MEDIA_TYPE_BY_EXTENSION[extension]) {
61
+ return MEDIA_TYPE_BY_EXTENSION[extension];
62
+ }
63
+
64
+ return metadataMediaType || UNKNOWN_MEDIA_TYPE;
65
+ }
11
66
 
12
67
  function maskIdentifier(value: string): string {
13
68
  if (value.length <= 8) return value;
14
69
  return `${value.slice(0, 4)}...${value.slice(-4)}`;
15
70
  }
16
71
 
72
+ type StorageListClient = {
73
+ storage: {
74
+ from: (bucket: string) => {
75
+ list: (
76
+ path: string,
77
+ options: Record<string, unknown>
78
+ ) => Promise<{
79
+ data:
80
+ | { name: string; metadata?: Record<string, unknown> | null }[]
81
+ | null;
82
+ error: unknown;
83
+ }>;
84
+ };
85
+ };
86
+ };
87
+
88
+ export async function listAllChatResourceFiles(
89
+ sbAdmin: StorageListClient,
90
+ storagePath: string
91
+ ) {
92
+ const files: { name: string; metadata?: Record<string, unknown> | null }[] =
93
+ [];
94
+
95
+ for (let page = 0; page < STORAGE_LIST_MAX_PAGES; page += 1) {
96
+ const { data, error } = await sbAdmin.storage
97
+ .from('workspaces')
98
+ .list(storagePath, {
99
+ limit: STORAGE_LIST_PAGE_SIZE,
100
+ offset: page * STORAGE_LIST_PAGE_SIZE,
101
+ sortBy: { column: 'created_at', order: 'asc' },
102
+ });
103
+
104
+ if (error) {
105
+ console.error('Error listing files:', error);
106
+ // Keep whatever paged in rather than dropping the whole set.
107
+ return { files, truncated: false };
108
+ }
109
+
110
+ if (!data || data.length === 0) break;
111
+
112
+ files.push(...data);
113
+ if (data.length < STORAGE_LIST_PAGE_SIZE) {
114
+ return { files, truncated: false };
115
+ }
116
+ }
117
+
118
+ return {
119
+ files,
120
+ truncated: files.length >= STORAGE_LIST_PAGE_SIZE * STORAGE_LIST_MAX_PAGES,
121
+ };
122
+ }
123
+
17
124
  async function getAllChatFiles(
18
125
  wsId: string,
19
126
  chatId: string,
@@ -23,24 +130,19 @@ async function getAllChatFiles(
23
130
  const sbAdmin = await createAdminClient();
24
131
 
25
132
  const storagePath = `${wsId}/chats/ai/resources/${chatId}`;
26
- const { data: files, error: listError } = await sbAdmin.storage
27
- .from('workspaces')
28
- .list(storagePath, {
29
- sortBy: { column: 'created_at', order: 'asc' },
30
- });
31
-
32
- if (listError) {
33
- console.error('Error listing files:', listError);
34
- return [];
35
- }
133
+ const { files, truncated } = await listAllChatResourceFiles(
134
+ sbAdmin,
135
+ storagePath
136
+ );
36
137
 
37
138
  console.info('[Google Chat Files] listed chat files', {
38
139
  wsId: maskIdentifier(wsId),
39
140
  chatId: maskIdentifier(chatId),
40
- fileCount: files?.length ?? 0,
141
+ fileCount: files.length,
142
+ truncated,
41
143
  });
42
144
 
43
- if (!files || files.length === 0) {
145
+ if (files.length === 0) {
44
146
  return [];
45
147
  }
46
148
 
@@ -57,10 +159,11 @@ async function getAllChatFiles(
57
159
 
58
160
  const file = files[currentIndex]!;
59
161
  const fileName = file.name || 'unknown';
60
- const mediaType =
61
- file.metadata?.mediaType ||
62
- file.metadata?.mimetype ||
63
- 'application/octet-stream';
162
+ const mediaType = resolveAttachmentMediaType(
163
+ fileName,
164
+ (file.metadata?.mediaType as string | undefined) ||
165
+ (file.metadata?.mimetype as string | undefined)
166
+ );
64
167
 
65
168
  const { data: fileData, error: downloadError } = await sbAdmin.storage
66
169
  .from('workspaces')
@@ -116,6 +219,14 @@ function addFilesToContent(
116
219
  'text/csv',
117
220
  'application/json',
118
221
  'text/markdown',
222
+ // Gemini accepts audio natively; without these an attached recording
223
+ // reached the model only as a "cannot be passed directly" note.
224
+ 'audio/aac',
225
+ 'audio/flac',
226
+ 'audio/mp4',
227
+ 'audio/mpeg',
228
+ 'audio/ogg',
229
+ 'audio/wav',
119
230
  ]);
120
231
 
121
232
  if (typeof existingContent === 'string') {
@@ -132,8 +243,21 @@ function addFilesToContent(
132
243
  }
133
244
  }
134
245
 
246
+ let injectedBytes = 0;
247
+ let skippedForBudget = 0;
248
+
135
249
  for (const file of chatFiles) {
136
250
  const { content, mediaType, fileName } = file;
251
+ const byteLength =
252
+ content instanceof ArrayBuffer ? content.byteLength : content.length;
253
+
254
+ // Oldest-first: an early attachment the conversation is actually about
255
+ // should not be evicted by a later one.
256
+ if (injectedBytes + byteLength > MAX_INJECTED_ATTACHMENT_BYTES) {
257
+ skippedForBudget += 1;
258
+ continue;
259
+ }
260
+ injectedBytes += byteLength;
137
261
 
138
262
  if (mediaType.startsWith('image/')) {
139
263
  const imagePart: ImagePart = {
@@ -172,6 +296,14 @@ function addFilesToContent(
172
296
  }
173
297
  }
174
298
 
299
+ if (skippedForBudget > 0) {
300
+ // Say so rather than answering as though nothing was attached.
301
+ contentParts.push({
302
+ type: 'text',
303
+ text: `${skippedForBudget} older attachment(s) were omitted from this request because the conversation exceeds the attachment size budget. Ask the user to re-attach anything you need.`,
304
+ });
305
+ }
306
+
175
307
  return contentParts;
176
308
  }
177
309
 
@@ -188,7 +188,10 @@ export async function beginAiStudioRun(
188
188
  }
189
189
 
190
190
  export type BeginExternalAiStudioRunInput = {
191
- 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;
192
195
  externalAppId: string;
193
196
  feature: string;
194
197
  idempotencyKey?: string | null;
@@ -205,13 +208,18 @@ export async function beginExternalAiStudioRun(
205
208
  const { data, error } = await sbAdmin
206
209
  .schema('private')
207
210
  .rpc('begin_external_ai_studio_run', {
211
+ p_api_key_id: input.apiKeyId ?? undefined,
208
212
  p_external_app_id: input.externalAppId,
209
213
  p_feature: input.feature,
210
214
  p_idempotency_key: input.idempotencyKey ?? undefined,
211
215
  p_metadata: input.metadata,
212
216
  p_model_id: input.modelId,
213
217
  p_request_id: input.requestId,
214
- 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,
215
223
  p_ws_id: input.workspaceId,
216
224
  });
217
225
 
@@ -277,8 +285,20 @@ export async function settleAiStudioRun(
277
285
  }
278
286
  }
279
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
+
280
300
  export async function settleExternalAiStudioRun(
281
- input: Omit<SettleAiStudioRunInput, 'actualCredits'>
301
+ input: SettleExternalAiStudioRunInput
282
302
  ): Promise<void> {
283
303
  const sbAdmin = await createAdminClient({ noCookie: true });
284
304
  const { data, error } = await sbAdmin
@@ -297,6 +317,7 @@ export async function settleExternalAiStudioRun(
297
317
  p_reasoning_tokens: input.reasoningTokens ?? 0,
298
318
  p_run_id: input.runId,
299
319
  p_status: input.status,
320
+ p_unmetered_credits: Math.max(0, input.unmeteredCredits ?? 0),
300
321
  });
301
322
 
302
323
  if (error || !data?.[0]?.success) {