@tuturuuu/ai 0.2.1 → 0.3.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/README.md CHANGED
@@ -61,6 +61,11 @@ yarn add @tuturuuu/ai
61
61
  bun add @tuturuuu/ai
62
62
  ```
63
63
 
64
+ Published artifacts replace local `workspace:*` dependencies with exact
65
+ registry versions before packing. If a public consumer cannot install this
66
+ package, rerun the package release from the current production commit so its
67
+ manifest points at published Tuturuuu package versions.
68
+
64
69
  ## Development
65
70
 
66
71
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/ai",
3
3
  "license": "MIT",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -19,96 +19,96 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "test": "vitest run",
22
- "type-check": "tsgo --project tsconfig.typecheck.json"
22
+ "type-check": "tsc --project tsconfig.typecheck.json"
23
23
  },
24
24
  "dependencies": {
25
- "@ai-sdk/amazon-bedrock": "^4.0.113",
26
- "@ai-sdk/anthropic": "^3.0.81",
27
- "@ai-sdk/azure": "^3.0.70",
28
- "@ai-sdk/cerebras": "^2.0.53",
29
- "@ai-sdk/cohere": "^3.0.36",
30
- "@ai-sdk/deepgram": "^2.0.33",
31
- "@ai-sdk/deepinfra": "^2.0.51",
32
- "@ai-sdk/deepseek": "^2.0.34",
33
- "@ai-sdk/elevenlabs": "^2.0.33",
34
- "@ai-sdk/fal": "^2.0.34",
35
- "@ai-sdk/fireworks": "^2.0.52",
36
- "@ai-sdk/gateway": "^3.0.125",
37
- "@ai-sdk/gladia": "^2.0.33",
38
- "@ai-sdk/google": "^3.0.80",
39
- "@ai-sdk/google-vertex": "^4.0.142",
40
- "@ai-sdk/groq": "^3.0.39",
41
- "@ai-sdk/hume": "^2.0.33",
42
- "@ai-sdk/lmnt": "^2.0.33",
43
- "@ai-sdk/luma": "^2.0.33",
44
- "@ai-sdk/mistral": "^3.0.37",
45
- "@ai-sdk/openai": "^3.0.68",
46
- "@ai-sdk/openai-compatible": "^2.0.47",
47
- "@ai-sdk/perplexity": "^3.0.33",
48
- "@ai-sdk/react": "^3.0.199",
49
- "@ai-sdk/replicate": "^2.0.33",
50
- "@ai-sdk/revai": "^2.0.33",
51
- "@ai-sdk/togetherai": "^2.0.52",
52
- "@ai-sdk/xai": "^3.0.93",
25
+ "@ai-sdk/amazon-bedrock": "^5.0.24",
26
+ "@ai-sdk/anthropic": "^4.0.16",
27
+ "@ai-sdk/azure": "^4.0.17",
28
+ "@ai-sdk/cerebras": "^3.0.12",
29
+ "@ai-sdk/cohere": "^4.0.11",
30
+ "@ai-sdk/deepgram": "^3.0.11",
31
+ "@ai-sdk/deepinfra": "^3.0.12",
32
+ "@ai-sdk/deepseek": "^3.0.12",
33
+ "@ai-sdk/elevenlabs": "^3.0.12",
34
+ "@ai-sdk/fal": "^3.0.12",
35
+ "@ai-sdk/fireworks": "^3.0.13",
36
+ "@ai-sdk/gateway": "^4.0.23",
37
+ "@ai-sdk/gladia": "^3.0.11",
38
+ "@ai-sdk/google": "^4.0.18",
39
+ "@ai-sdk/google-vertex": "^5.0.22",
40
+ "@ai-sdk/groq": "^4.0.12",
41
+ "@ai-sdk/hume": "^3.0.11",
42
+ "@ai-sdk/lmnt": "^3.0.11",
43
+ "@ai-sdk/luma": "^3.0.12",
44
+ "@ai-sdk/mistral": "^4.0.13",
45
+ "@ai-sdk/openai": "^4.0.16",
46
+ "@ai-sdk/openai-compatible": "^3.0.12",
47
+ "@ai-sdk/perplexity": "^4.0.12",
48
+ "@ai-sdk/react": "^4.0.34",
49
+ "@ai-sdk/replicate": "^3.0.12",
50
+ "@ai-sdk/revai": "^3.0.11",
51
+ "@ai-sdk/togetherai": "^3.0.13",
52
+ "@ai-sdk/xai": "^4.0.16",
53
53
  "@beeper/chat-adapter-matrix": "^0.2.0",
54
54
  "@bitbasti/chat-adapter-webex": "^0.1.0",
55
- "@chat-adapter/discord": "^4.30.0",
56
- "@chat-adapter/gchat": "^4.30.0",
57
- "@chat-adapter/github": "^4.30.0",
58
- "@chat-adapter/linear": "^4.30.0",
59
- "@chat-adapter/messenger": "^4.30.0",
60
- "@chat-adapter/slack": "^4.30.0",
61
- "@chat-adapter/state-ioredis": "^4.30.0",
62
- "@chat-adapter/state-memory": "^4.30.0",
63
- "@chat-adapter/state-pg": "^4.30.0",
64
- "@chat-adapter/state-redis": "^4.30.0",
65
- "@chat-adapter/teams": "^4.30.0",
66
- "@chat-adapter/telegram": "^4.30.0",
67
- "@chat-adapter/web": "^4.30.0",
68
- "@chat-adapter/whatsapp": "^4.30.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",
69
69
  "@json-render/core": "^0.19.0",
70
70
  "@json-render/react": "^0.19.0",
71
- "@liveblocks/chat-sdk-adapter": "^3.19.4",
71
+ "@liveblocks/chat-sdk-adapter": "^3.22.0",
72
72
  "@octokit/rest": "^22.0.1",
73
73
  "@resend/chat-sdk-adapter": "^0.2.2",
74
74
  "@streamdown/cjk": "^1.0.3",
75
75
  "@streamdown/code": "^1.1.1",
76
76
  "@streamdown/math": "^1.0.2",
77
77
  "@streamdown/mermaid": "^1.0.2",
78
- "@tuturuuu/google": "0.0.1",
79
- "@tuturuuu/internal-api": "0.4.1",
80
- "@tuturuuu/supabase": "0.3.3",
81
- "@tuturuuu/utils": "0.5.1",
82
- "@vercel/sandbox": "^2.1.1",
83
- "@zernio/chat-sdk-adapter": "^0.3.0",
84
- "ai": "^6.0.197",
85
- "bash-tool": "^1.3.17",
86
- "chat": "^4.30.0",
78
+ "@tuturuuu/google": "0.0.3",
79
+ "@tuturuuu/internal-api": "0.20.0",
80
+ "@tuturuuu/supabase": "0.4.1",
81
+ "@tuturuuu/utils": "0.17.0",
82
+ "@vercel/sandbox": "^2.7.1",
83
+ "@zernio/chat-sdk-adapter": "^0.4.0",
84
+ "ai": "^7.0.31",
85
+ "bash-tool": "^1.3.18",
86
+ "chat": "^4.34.0",
87
87
  "chat-adapter-baileys": "^2.0.3",
88
88
  "chat-adapter-blooio": "^0.1.0",
89
- "chat-adapter-imessage": "^0.1.1",
89
+ "chat-adapter-imessage": "^1.1.0",
90
90
  "chat-adapter-mattermost": "^1.1.3",
91
91
  "chat-adapter-sendblue": "^0.2.0",
92
92
  "chat-adapter-zalo": "^0.1.0",
93
93
  "chat-state-cloudflare-do": "^0.2.0",
94
94
  "chat-state-mysql": "^0.1.0",
95
95
  "dayjs": "^1.11.20",
96
- "next": "^16.2.7",
96
+ "next": "^16.3.0-preview.6",
97
97
  "qrcode": "^1.5.4",
98
98
  "react": "^19.2.7",
99
99
  "react-dom": "^19.2.7",
100
100
  "streamdown": "^2.5.0",
101
- "uuid": "^14.0.0",
101
+ "uuid": "^14.0.1",
102
102
  "zca-js": "^2.1.2",
103
103
  "zod": "^4.4.3"
104
104
  },
105
105
  "devDependencies": {
106
- "@tuturuuu/types": "0.6.1",
106
+ "@tuturuuu/types": "0.19.0",
107
107
  "@tuturuuu/typescript-config": "0.1.1",
108
- "@types/node": "^25.9.2",
108
+ "@types/node": "^26.1.1",
109
109
  "@types/qrcode": "^1.5.6",
110
110
  "@types/react": "^19.2.17",
111
- "typescript": "^6.0.3"
111
+ "typescript": "7.0.2"
112
112
  },
113
113
  "exports": {
114
114
  "./*": "./src/*.ts"
@@ -18,6 +18,7 @@ import {
18
18
  resolvePlanModel,
19
19
  } from '../../credits/resolve-plan-model';
20
20
  import { withAiMemory } from '../../memory';
21
+ import { createGoogleSearchToolSet } from '../../tools/google-search-tool';
21
22
  import type { CreditSource as SharedCreditSource } from '../credit-source';
22
23
  import {
23
24
  shouldForceGoogleSearchForLatestUserMessage,
@@ -495,9 +496,7 @@ export function createPOST(
495
496
  );
496
497
 
497
498
  // Provider-native Google Search tool for non-Mira mode.
498
- const googleSearchTool = {
499
- google_search: google.tools.googleSearch({}),
500
- };
499
+ const googleSearchTool = createGoogleSearchToolSet();
501
500
 
502
501
  type PrepareStep = NonNullable<
503
502
  NonNullable<Parameters<typeof streamText>[0]>['prepareStep']
@@ -83,18 +83,12 @@ async function loadChatSdkFactory(
83
83
  }
84
84
 
85
85
  function isAdapter(value: unknown): value is Adapter {
86
- return (
87
- typeof value === 'object' &&
88
- value !== null &&
89
- 'handleWebhook' in value &&
90
- 'name' in value
91
- );
86
+ return value instanceof Object && 'handleWebhook' in value && 'name' in value;
92
87
  }
93
88
 
94
89
  function isStateAdapter(value: unknown): value is StateAdapter {
95
90
  return (
96
- typeof value === 'object' &&
97
- value !== null &&
91
+ value instanceof Object &&
98
92
  'get' in value &&
99
93
  'set' in value &&
100
94
  'delete' in value
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type Adapter,
3
3
  type AdapterPostableMessage,
4
+ type Attachment,
4
5
  type ChatInstance,
5
6
  type FetchOptions,
6
7
  type FetchResult,
@@ -163,6 +164,7 @@ export interface ZaloPersonalPhoneSyncOptions {
163
164
  maxPulls?: number;
164
165
  minSeqId?: number;
165
166
  pullDelayMs?: number;
167
+ signal?: AbortSignal;
166
168
  tempKey?: string;
167
169
  useListenerWakeup?: boolean;
168
170
  }
@@ -211,8 +213,8 @@ const DEFAULT_HISTORY_MAX_PAGES_PER_TYPE = 1000;
211
213
  const DEFAULT_HISTORY_MESSAGES_PER_GROUP = 200;
212
214
  const DEFAULT_HISTORY_PAGE_TIMEOUT_MS = 8000;
213
215
  const HISTORY_CONNECT_TIMEOUT_MS = 5000;
214
- const DEFAULT_PHONE_SYNC_MAX_PULLS = 90;
215
- const DEFAULT_PHONE_SYNC_PULL_DELAY_MS = 1000;
216
+ const DEFAULT_PHONE_SYNC_MAX_PULLS = 4;
217
+ const DEFAULT_PHONE_SYNC_PULL_DELAY_MS = 20_000;
216
218
  const PHONE_SYNC_PC_NAME_MAX_LENGTH = 80;
217
219
  const ZALO_LISTENER_SOCKET_OPEN_STATE = 1;
218
220
  const ZALO_PHONE_SYNC_REQUEST_CMD = 590;
@@ -1023,15 +1025,16 @@ export function createZaloPersonalAdapter(
1023
1025
  });
1024
1026
  }
1025
1027
 
1028
+ const attachments = extractZaloPersonalAttachments(raw, config);
1026
1029
  const text =
1027
1030
  typeof raw.data.content === 'string'
1028
1031
  ? raw.data.content
1029
- : '[Unsupported Zalo message]';
1032
+ : zaloAttachmentFallbackText(raw.data.msgType, attachments);
1030
1033
  const authorId =
1031
1034
  raw.data.uidFrom || raw.data.userId || (raw.isSelf ? status.ownId : '');
1032
1035
 
1033
1036
  return new Message<ZaloPersonalRawMessage>({
1034
- attachments: [],
1037
+ attachments,
1035
1038
  author: {
1036
1039
  fullName: raw.data.dName || authorId || 'Zalo user',
1037
1040
  isBot: raw.isSelf,
@@ -1245,6 +1248,8 @@ export function createZaloPersonalAdapter(
1245
1248
  const useListenerWakeup = options.useListenerWakeup ?? true;
1246
1249
 
1247
1250
  try {
1251
+ assertPhoneSyncNotAborted(options.signal);
1252
+
1248
1253
  if (useListenerWakeup) {
1249
1254
  attachListeners(apiInstance);
1250
1255
  await adapter.startPersonalListener();
@@ -1276,9 +1281,8 @@ export function createZaloPersonalAdapter(
1276
1281
  }
1277
1282
 
1278
1283
  for (let attempt = 0; attempt < maxPulls; attempt += 1) {
1279
- if (attempt > 0) {
1280
- await delay(pullDelayMs);
1281
- }
1284
+ await delay(pullDelayMs, options.signal);
1285
+ assertPhoneSyncNotAborted(options.signal);
1282
1286
 
1283
1287
  try {
1284
1288
  const pullResponse = await transferApi.tuturuuuPullMobileMessages?.(
@@ -1332,7 +1336,11 @@ export function createZaloPersonalAdapter(
1332
1336
  }
1333
1337
  }
1334
1338
  } catch (error) {
1335
- lastError = error instanceof Error ? error.message : String(error);
1339
+ lastError = options.signal?.aborted
1340
+ ? 'zalo_personal_phone_sync_cancelled'
1341
+ : error instanceof Error
1342
+ ? error.message
1343
+ : String(error);
1336
1344
  } finally {
1337
1345
  if (requestAccepted && messages.length > 0) {
1338
1346
  cleaned =
@@ -2124,8 +2132,229 @@ function stringValue(value: unknown) {
2124
2132
  return '';
2125
2133
  }
2126
2134
 
2127
- function delay(ms: number) {
2128
- return new Promise((resolve) => setTimeout(resolve, ms));
2135
+ const ZALO_MEDIA_URL_KEYS = [
2136
+ 'downloadUrl',
2137
+ 'fileUrl',
2138
+ 'hdUrl',
2139
+ 'href',
2140
+ 'normalUrl',
2141
+ 'originUrl',
2142
+ 'thumb',
2143
+ 'thumbnail',
2144
+ 'thumbUrl',
2145
+ 'url',
2146
+ 'voiceUrl',
2147
+ ] as const;
2148
+
2149
+ function extractZaloPersonalAttachments(
2150
+ raw: ZcaMessage,
2151
+ config: ZaloPersonalAdapterConfig
2152
+ ): Attachment[] {
2153
+ const content = normalizeZaloAttachmentContent(raw.data.content);
2154
+ if (!content) return [];
2155
+
2156
+ const urls = collectZaloMediaUrls(content);
2157
+ if (urls.length === 0) return [];
2158
+
2159
+ const type = zaloAttachmentType(raw.data.msgType);
2160
+ const preferredUrl = pickPreferredZaloMediaUrl(urls);
2161
+ const name = zaloAttachmentFilename(content, preferredUrl, type);
2162
+ const mimeType = inferZaloAttachmentMimeType(name, type);
2163
+ const size = numericValue(
2164
+ content.fileSize ?? content.file_size ?? content.size ?? content.totalSize
2165
+ );
2166
+
2167
+ return [
2168
+ {
2169
+ fetchData: async () => {
2170
+ const response = await fetch(preferredUrl, {
2171
+ headers: {
2172
+ Cookie: zaloCookieHeader(config.cookieJson),
2173
+ 'User-Agent': config.userAgent,
2174
+ },
2175
+ signal: AbortSignal.timeout(30_000),
2176
+ });
2177
+ if (!response.ok) {
2178
+ throw new Error(
2179
+ `zalo_personal_media_fetch_failed_${response.status}`
2180
+ );
2181
+ }
2182
+ const declaredSize = Number(response.headers.get('content-length'));
2183
+ if (Number.isFinite(declaredSize) && declaredSize > 25 * 1024 * 1024) {
2184
+ throw new Error('zalo_personal_media_too_large');
2185
+ }
2186
+ return Buffer.from(await response.arrayBuffer());
2187
+ },
2188
+ height: numericValue(content.height),
2189
+ mimeType,
2190
+ name,
2191
+ size,
2192
+ type,
2193
+ url: preferredUrl,
2194
+ width: numericValue(content.width),
2195
+ },
2196
+ ];
2197
+ }
2198
+
2199
+ function normalizeZaloAttachmentContent(value: unknown) {
2200
+ if (isRecord(value)) return value;
2201
+ if (typeof value !== 'string') return null;
2202
+
2203
+ const trimmed = value.trim();
2204
+ if (!trimmed.startsWith('{')) return null;
2205
+
2206
+ try {
2207
+ const parsed = JSON.parse(trimmed);
2208
+ return isRecord(parsed) ? parsed : null;
2209
+ } catch {
2210
+ return null;
2211
+ }
2212
+ }
2213
+
2214
+ function collectZaloMediaUrls(value: unknown) {
2215
+ const urls: string[] = [];
2216
+
2217
+ visitRecordValues(value, (key, candidate) => {
2218
+ if (
2219
+ ZALO_MEDIA_URL_KEYS.includes(
2220
+ key as (typeof ZALO_MEDIA_URL_KEYS)[number]
2221
+ ) &&
2222
+ typeof candidate === 'string' &&
2223
+ /^https?:\/\//u.test(candidate)
2224
+ ) {
2225
+ urls.push(candidate);
2226
+ }
2227
+ });
2228
+
2229
+ return [...new Set(urls)];
2230
+ }
2231
+
2232
+ function pickPreferredZaloMediaUrl(urls: string[]) {
2233
+ return (
2234
+ urls.find((url) => /(?:hd|origin|download|file)/iu.test(url)) ?? urls[0]!
2235
+ );
2236
+ }
2237
+
2238
+ function zaloAttachmentType(msgType: string): Attachment['type'] {
2239
+ if (/voice|audio/iu.test(msgType)) return 'audio';
2240
+ if (/video/iu.test(msgType)) return 'video';
2241
+ if (/photo|image|sticker|gif|doodle/iu.test(msgType)) return 'image';
2242
+ return 'file';
2243
+ }
2244
+
2245
+ function zaloAttachmentFilename(
2246
+ content: Record<string, unknown>,
2247
+ url: string,
2248
+ type: Attachment['type']
2249
+ ) {
2250
+ const explicit =
2251
+ stringValue(content.fileName) ||
2252
+ stringValue(content.filename) ||
2253
+ stringValue(content.title);
2254
+ if (explicit) return explicit.slice(0, 240);
2255
+
2256
+ try {
2257
+ const pathname = new URL(url).pathname;
2258
+ const candidate = decodeURIComponent(pathname.split('/').at(-1) ?? '');
2259
+ if (candidate?.includes('.')) return candidate.slice(0, 240);
2260
+ } catch {
2261
+ // Fall through to a stable generic name.
2262
+ }
2263
+
2264
+ const extension =
2265
+ type === 'image'
2266
+ ? 'jpg'
2267
+ : type === 'video'
2268
+ ? 'mp4'
2269
+ : type === 'audio'
2270
+ ? 'm4a'
2271
+ : 'bin';
2272
+ return `zalo-${type}.${extension}`;
2273
+ }
2274
+
2275
+ function inferZaloAttachmentMimeType(name: string, type: Attachment['type']) {
2276
+ const extension = name.split('.').at(-1)?.toLowerCase();
2277
+ const byExtension: Record<string, string> = {
2278
+ gif: 'image/gif',
2279
+ jpeg: 'image/jpeg',
2280
+ jpg: 'image/jpeg',
2281
+ m4a: 'audio/mp4',
2282
+ mov: 'video/quicktime',
2283
+ mp3: 'audio/mpeg',
2284
+ mp4: 'video/mp4',
2285
+ ogg: 'audio/ogg',
2286
+ pdf: 'application/pdf',
2287
+ png: 'image/png',
2288
+ webm: 'video/webm',
2289
+ webp: 'image/webp',
2290
+ };
2291
+
2292
+ return (
2293
+ (extension && byExtension[extension]) ||
2294
+ (type === 'file' ? 'application/octet-stream' : `${type}/*`)
2295
+ );
2296
+ }
2297
+
2298
+ function zaloAttachmentFallbackText(
2299
+ msgType: string,
2300
+ attachments: Attachment[]
2301
+ ) {
2302
+ if (attachments.length === 0) return '[Unsupported Zalo message]';
2303
+ const type = zaloAttachmentType(msgType);
2304
+ return `[Zalo ${type}]`;
2305
+ }
2306
+
2307
+ function numericValue(value: unknown) {
2308
+ const numeric =
2309
+ typeof value === 'number'
2310
+ ? value
2311
+ : typeof value === 'string'
2312
+ ? Number.parseInt(value, 10)
2313
+ : Number.NaN;
2314
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined;
2315
+ }
2316
+
2317
+ function zaloCookieHeader(cookieJson: string) {
2318
+ try {
2319
+ const parsed = JSON.parse(cookieJson) as unknown;
2320
+ if (!Array.isArray(parsed)) return '';
2321
+ return parsed
2322
+ .filter(isRecord)
2323
+ .map((cookie) => {
2324
+ const name = stringValue(cookie.name);
2325
+ const value = stringValue(cookie.value);
2326
+ return name && value ? `${name}=${value}` : '';
2327
+ })
2328
+ .filter(Boolean)
2329
+ .join('; ');
2330
+ } catch {
2331
+ return '';
2332
+ }
2333
+ }
2334
+
2335
+ function delay(ms: number, signal?: AbortSignal) {
2336
+ if (signal?.aborted) {
2337
+ return Promise.reject(new Error('zalo_personal_phone_sync_cancelled'));
2338
+ }
2339
+
2340
+ return new Promise<void>((resolve, reject) => {
2341
+ const timer = setTimeout(() => {
2342
+ signal?.removeEventListener('abort', onAbort);
2343
+ resolve();
2344
+ }, ms);
2345
+ const onAbort = () => {
2346
+ clearTimeout(timer);
2347
+ reject(new Error('zalo_personal_phone_sync_cancelled'));
2348
+ };
2349
+
2350
+ signal?.addEventListener('abort', onAbort, { once: true });
2351
+ });
2352
+ }
2353
+
2354
+ function assertPhoneSyncNotAborted(signal?: AbortSignal) {
2355
+ if (signal?.aborted) {
2356
+ throw new Error('zalo_personal_phone_sync_cancelled');
2357
+ }
2129
2358
  }
2130
2359
 
2131
2360
  function dateFromZaloTimestamp(value: string) {
package/src/effect.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type {
2
+ CreditCheckResult,
3
+ CreditDeductionResult,
4
+ DeductCreditsParams,
5
+ } from '@tuturuuu/ai/credits/types';
6
+ import {
7
+ Context,
8
+ Effect,
9
+ fromPromise,
10
+ Layer,
11
+ type TuturuuuEffectError,
12
+ } from '@tuturuuu/utils/effect';
13
+ import { checkAiCredits, deductAiCredits } from './credits/check-credits';
14
+ import type { AiFeature } from './credits/constants';
15
+
16
+ export interface AiCreditsServiceShape {
17
+ readonly checkAiCredits: typeof checkAiCredits;
18
+ readonly deductAiCredits: typeof deductAiCredits;
19
+ }
20
+
21
+ export class AiCreditsService extends Context.Tag(
22
+ '@tuturuuu/ai/AiCreditsService'
23
+ )<AiCreditsService, AiCreditsServiceShape>() {}
24
+
25
+ export const AiCreditsLive = Layer.succeed(AiCreditsService, {
26
+ checkAiCredits,
27
+ deductAiCredits,
28
+ });
29
+
30
+ export function checkAiCreditsEffect(
31
+ wsId: string | undefined,
32
+ modelId: string,
33
+ feature: AiFeature,
34
+ opts?: { userId?: string; estimatedInputTokens?: number }
35
+ ): Effect.Effect<CreditCheckResult, TuturuuuEffectError, AiCreditsService> {
36
+ return Effect.gen(function* () {
37
+ const service = yield* AiCreditsService;
38
+
39
+ return yield* fromPromise(
40
+ () => service.checkAiCredits(wsId, modelId, feature, opts),
41
+ {
42
+ code: 'AI_CREDIT_CHECK_FAILED',
43
+ message: 'AI credit check failed.',
44
+ context: { feature, modelId, wsId },
45
+ }
46
+ );
47
+ });
48
+ }
49
+
50
+ export function deductAiCreditsEffect(
51
+ params: DeductCreditsParams
52
+ ): Effect.Effect<CreditDeductionResult, TuturuuuEffectError, AiCreditsService> {
53
+ return Effect.gen(function* () {
54
+ const service = yield* AiCreditsService;
55
+
56
+ return yield* fromPromise(() => service.deductAiCredits(params), {
57
+ code: 'AI_CREDIT_DEDUCTION_FAILED',
58
+ message: 'AI credit deduction failed.',
59
+ context: {
60
+ feature: params.feature,
61
+ modelId: params.modelId,
62
+ wsId: params.wsId,
63
+ },
64
+ });
65
+ });
66
+ }
@@ -259,7 +259,7 @@ export function createPOST(
259
259
  usage: {
260
260
  inputTokens: usage.inputTokens ?? 0,
261
261
  outputTokens: usage.outputTokens ?? 0,
262
- reasoningTokens: usage.reasoningTokens ?? 0,
262
+ reasoningTokens: usage.outputTokenDetails?.reasoningTokens ?? 0,
263
263
  totalTokens: usage.totalTokens ?? 0,
264
264
  },
265
265
  finishReason,
@@ -274,7 +274,7 @@ export function createPOST(
274
274
  finish_reason: String(finishReason),
275
275
  input_tokens: usage.inputTokens ?? 0,
276
276
  output_tokens: usage.outputTokens ?? 0,
277
- reasoning_tokens: usage.reasoningTokens ?? 0,
277
+ reasoning_tokens: usage.outputTokenDetails?.reasoningTokens ?? 0,
278
278
  total_tokens: usage.totalTokens ?? 0,
279
279
  system_prompt: configs.systemPrompt ?? '',
280
280
  };
@@ -296,10 +296,7 @@ export function createPOST(
296
296
  modelId: effectiveModel,
297
297
  inputTokens: usage.inputTokens ?? 0,
298
298
  outputTokens: usage.outputTokens ?? 0,
299
- reasoningTokens:
300
- usage.outputTokenDetails?.reasoningTokens ??
301
- usage.reasoningTokens ??
302
- 0,
299
+ reasoningTokens: usage.outputTokenDetails?.reasoningTokens ?? 0,
303
300
  feature: 'generate',
304
301
  executionId: execData?.id,
305
302
  }).catch((err) => console.error('Failed to deduct AI credits:', err));
package/src/mind/route.ts CHANGED
@@ -5,7 +5,13 @@ import {
5
5
  normalizeWorkspaceId,
6
6
  verifyWorkspaceMembershipType,
7
7
  } from '@tuturuuu/utils/workspace-helper';
8
- import { consumeStream, gateway, stepCountIs, streamText } from 'ai';
8
+ import {
9
+ consumeStream,
10
+ gateway,
11
+ type LanguageModelUsage,
12
+ stepCountIs,
13
+ streamText,
14
+ } from 'ai';
9
15
  import { type NextRequest, NextResponse } from 'next/server';
10
16
  import { z } from 'zod';
11
17
  import type { CreditSource as SharedCreditSource } from '../chat/credit-source';
@@ -31,6 +37,11 @@ import { createMiraStreamTools } from '../tools/mira-tools';
31
37
  import { createMindStreamTools, type MindToolCallbacks } from './tools';
32
38
 
33
39
  type AuthOk = Extract<AiRouteAuthResult, { ok: true }>;
40
+ type MindUsage = Partial<
41
+ Pick<LanguageModelUsage, 'inputTokens' | 'outputTokens' | 'totalTokens'>
42
+ > & {
43
+ outputTokenDetails?: Partial<LanguageModelUsage['outputTokenDetails']>;
44
+ };
34
45
 
35
46
  const MindChatBodySchema = z.object({
36
47
  boardId: z.guid().nullable().optional(),
@@ -589,22 +600,34 @@ export function createPOST(callbacks: MindRouteCallbacks) {
589
600
  userId: auth.user.id,
590
601
  wsId: access.wsId,
591
602
  });
603
+ const telemetryRuntimeContext = {
604
+ boardId: parsedBody.boardId ?? 'none',
605
+ clientRunId: parsedBody.clientRunId ?? 'none',
606
+ creditSource: requestedCreditSource,
607
+ creditWsId: billingWsId ?? access.wsId,
608
+ model: resolvedModelId,
609
+ threadId,
610
+ thinkingMode: parsedBody.thinkingMode ?? 'fast',
611
+ writeMode,
612
+ wsId: access.wsId,
613
+ };
592
614
 
593
615
  const result = streamText({
594
616
  abortSignal: request.signal,
595
- experimental_telemetry: {
617
+ runtimeContext: telemetryRuntimeContext,
618
+ telemetry: {
596
619
  functionId: 'mind.ai.stream',
597
620
  isEnabled: true,
598
- metadata: {
599
- boardId: parsedBody.boardId ?? 'none',
600
- clientRunId: parsedBody.clientRunId ?? 'none',
601
- creditSource: requestedCreditSource,
602
- creditWsId: billingWsId ?? access.wsId,
603
- model: resolvedModelId,
604
- threadId,
605
- thinkingMode: parsedBody.thinkingMode ?? 'fast',
606
- writeMode,
607
- wsId: access.wsId,
621
+ includeRuntimeContext: {
622
+ boardId: true,
623
+ clientRunId: true,
624
+ creditSource: true,
625
+ creditWsId: true,
626
+ model: true,
627
+ threadId: true,
628
+ thinkingMode: true,
629
+ writeMode: true,
630
+ wsId: true,
608
631
  },
609
632
  },
610
633
  maxRetries: 0,
@@ -636,7 +659,7 @@ export function createPOST(callbacks: MindRouteCallbacks) {
636
659
  const toolResults = response.steps?.flatMap(
637
660
  (step) => step.toolResults ?? []
638
661
  );
639
- const usage = response.totalUsage ?? response.usage ?? {};
662
+ const usage: MindUsage = response.totalUsage ?? response.usage ?? {};
640
663
  await callbacks.persistMessage({
641
664
  boardId: parsedBody.boardId ?? null,
642
665
  content: response.text || '',
@@ -653,7 +676,7 @@ export function createPOST(callbacks: MindRouteCallbacks) {
653
676
  usage: {
654
677
  inputTokens: usage.inputTokens ?? 0,
655
678
  outputTokens: usage.outputTokens ?? 0,
656
- reasoningTokens: usage.reasoningTokens ?? 0,
679
+ reasoningTokens: usage.outputTokenDetails?.reasoningTokens ?? 0,
657
680
  totalTokens: usage.totalTokens ?? 0,
658
681
  },
659
682
  userId: auth.user.id,
@@ -664,7 +687,7 @@ export function createPOST(callbacks: MindRouteCallbacks) {
664
687
  inputTokens: usage.inputTokens ?? 0,
665
688
  modelId: resolvedModelId,
666
689
  outputTokens: usage.outputTokens ?? 0,
667
- reasoningTokens: usage.reasoningTokens ?? 0,
690
+ reasoningTokens: usage.outputTokenDetails?.reasoningTokens ?? 0,
668
691
  userId: auth.user.id,
669
692
  wsId: billingWsId ?? access.wsId,
670
693
  });
@@ -542,12 +542,9 @@ export function calculateIdealStartTimeForTask(
542
542
  now: Date
543
543
  ): Date {
544
544
  // Effective slot start (must be >= now, rounded to next 15-min)
545
- let effectiveSlotStart = new Date(slot.start);
546
- if (now > slot.start) {
547
- effectiveSlotStart = roundToNext15Minutes(now);
548
- } else {
549
- effectiveSlotStart = roundToNext15Minutes(slot.start);
550
- }
545
+ const effectiveSlotStart = roundToNext15Minutes(
546
+ now > slot.start ? now : slot.start
547
+ );
551
548
 
552
549
  // Latest possible start time to fit the duration
553
550
  const latestStart = new Date(slot.end.getTime() - duration * 60000);
@@ -123,7 +123,7 @@ function extractMeteredUsage(usage: LanguageModelUsage): MeteredUsage {
123
123
  inputTokens: normalizeTokenCount(usage.inputTokens),
124
124
  outputTokens: normalizeTokenCount(usage.outputTokens),
125
125
  reasoningTokens: normalizeTokenCount(
126
- usage.outputTokenDetails.reasoningTokens ?? usage.reasoningTokens
126
+ usage.outputTokenDetails.reasoningTokens
127
127
  ),
128
128
  };
129
129
  }
@@ -2,6 +2,7 @@ import { google } from '@ai-sdk/google';
2
2
  import { generateText, stepCountIs } from 'ai';
3
3
  import { z } from 'zod';
4
4
  import { withAiMemory } from '../../memory';
5
+ import { createGoogleSearchToolSet } from '../google-search-tool';
5
6
  import type { MiraToolContext } from '../mira-tools';
6
7
 
7
8
  const SEARCH_WRAPPER_MODEL = 'gemini-3.1-flash-lite';
@@ -85,9 +86,7 @@ async function runGoogleSearchWrapper(
85
86
  userId: ctx.userId,
86
87
  wsId: ctx.workspaceContext?.wsId ?? ctx.wsId,
87
88
  }),
88
- tools: {
89
- google_search: google.tools.googleSearch({}),
90
- },
89
+ tools: createGoogleSearchToolSet(),
91
90
  prompt,
92
91
  stopWhen: stepCountIs(4),
93
92
  ...(forceTool ? { toolChoice: 'required' as const } : {}),
@@ -0,0 +1,10 @@
1
+ import { google } from '@ai-sdk/google';
2
+ import type { ToolSet } from 'ai';
3
+
4
+ export function createGoogleSearchToolSet(): ToolSet {
5
+ return {
6
+ // Google executes this provider tool itself; AI SDK's public ToolSet type
7
+ // still narrows provider tools through the local-tool generic union.
8
+ google_search: google.tools.googleSearch({}) as unknown as ToolSet[string],
9
+ };
10
+ }