thaipass 0.0.0 → 0.1.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 +13 -21
- package/dist/index.d.ts +37 -13
- package/dist/index.js +1378 -8
- package/package.json +31 -29
- package/LICENSE +0 -21
package/dist/index.js
CHANGED
|
@@ -1,9 +1,1379 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { APICallError, NoSuchModelError } from "@ai-sdk/provider";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { parseJsonEventStream } from "@ai-sdk/provider-utils";
|
|
5
|
+
//#region ../core/src/aipass/models.ts
|
|
6
|
+
/**
|
|
7
|
+
* The chat ids the proxy knew when it was built. The account's catalog on
|
|
8
|
+
* `/loaders/list-models` decides what a chat request may name. This list
|
|
9
|
+
* stands in when the catalog cannot be read, seeds the docs, and feeds the
|
|
10
|
+
* drift warning. A media id needs per-model knowledge the catalog does not
|
|
11
|
+
* carry, so the media lists below stay the gate for their routes.
|
|
12
|
+
*/
|
|
13
|
+
const CHAT_MODELS = [
|
|
14
|
+
"gpt-5.6-terra",
|
|
15
|
+
"gpt-5.6-sol",
|
|
16
|
+
"claude-opus-5@azure",
|
|
17
|
+
"claude-sonnet-5@default",
|
|
18
|
+
"gemini-3.1-pro-preview",
|
|
19
|
+
"gemini-3.7-flash",
|
|
20
|
+
"gemini-3.1-flash-lite",
|
|
21
|
+
"glm-5.2",
|
|
22
|
+
"grok-4.3",
|
|
23
|
+
"DeepSeek-V3.2",
|
|
24
|
+
"minimax-m2-maas",
|
|
25
|
+
"qwen3-next-80b-a3b-instruct-maas",
|
|
26
|
+
"Kimi-K2.7-Code",
|
|
27
|
+
"Mistral-Large-3",
|
|
28
|
+
"Mistral-Medium-3",
|
|
29
|
+
"Llama-4-Scout-17B-16E-Instruct-1",
|
|
30
|
+
"Llama-4-Maverick-17B-128E-Instruct-FP8-1",
|
|
31
|
+
"pathumma-thaillm-8b",
|
|
32
|
+
"openai-deep-research",
|
|
33
|
+
"openthai2.0-legal@jts",
|
|
34
|
+
"sonar",
|
|
35
|
+
"sonar-reasoning-pro",
|
|
36
|
+
"sonar-deep-research"
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* AI Pass has no model category on `/loaders/list-models`; the web UI builds
|
|
40
|
+
* its tabs client-side, so the split lives here. Video is its own list
|
|
41
|
+
* because it is a polled job rather than a stream.
|
|
42
|
+
*/
|
|
43
|
+
const IMAGE_MODELS = [
|
|
44
|
+
"gpt-image-2",
|
|
45
|
+
"gemini-3-pro-image",
|
|
46
|
+
"gemini-2.5-flash-image",
|
|
47
|
+
"seedream-4.0",
|
|
48
|
+
"seedream-5.0-lite"
|
|
49
|
+
];
|
|
50
|
+
const VIDEO_MODELS = [
|
|
51
|
+
"seedance-2.0",
|
|
52
|
+
"seedance-2.0-fast",
|
|
53
|
+
"seedance-2.0-mini",
|
|
54
|
+
"veo-3.1-fast-generate-001"
|
|
55
|
+
];
|
|
56
|
+
const MUSIC_MODELS = ["lyria-3-pro-preview", "lyria-3-clip-preview"];
|
|
57
|
+
const MEDIA_MODELS = [
|
|
58
|
+
...IMAGE_MODELS,
|
|
59
|
+
...VIDEO_MODELS,
|
|
60
|
+
...MUSIC_MODELS
|
|
61
|
+
];
|
|
62
|
+
z.enum(IMAGE_MODELS, { error: "unknown image model, see GET /v1/models" });
|
|
63
|
+
z.enum(VIDEO_MODELS, { error: "unknown video model, see GET /v1/models" });
|
|
64
|
+
z.enum(MUSIC_MODELS, { error: "unknown music model, see GET /v1/models" });
|
|
65
|
+
const KINDS = new Map([
|
|
66
|
+
...IMAGE_MODELS.map((id) => [id, "image"]),
|
|
67
|
+
...VIDEO_MODELS.map((id) => [id, "video"]),
|
|
68
|
+
...MUSIC_MODELS.map((id) => [id, "music"])
|
|
69
|
+
]);
|
|
70
|
+
const kindOf = (modelId) => KINDS.get(modelId) ?? "chat";
|
|
71
|
+
z.string().min(1, { error: "model must not be empty, see GET /v1/models" }).meta({
|
|
72
|
+
description: "A chat model id from GET /v1/models. Ids are case-sensitive and Claude carries a @provider suffix.",
|
|
73
|
+
examples: [...CHAT_MODELS]
|
|
74
|
+
});
|
|
75
|
+
const DEFAULT_MODEL = "gemini-3.1-flash-lite";
|
|
76
|
+
const ANY_MODELS = [...CHAT_MODELS, ...MEDIA_MODELS];
|
|
77
|
+
z.enum(ANY_MODELS, { error: "unknown model, see GET /v1/models" });
|
|
78
|
+
const current = {
|
|
79
|
+
origin: "https://de.aipass.net",
|
|
80
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
|
|
9
81
|
};
|
|
82
|
+
const config = current;
|
|
83
|
+
/** Process-wide; the server calls it once at startup, a library consumer when the defaults are wrong. */
|
|
84
|
+
const configure = (changes) => {
|
|
85
|
+
Object.assign(current, changes);
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region ../core/src/lib/cache.ts
|
|
89
|
+
const ttlCache = (ttlMs) => {
|
|
90
|
+
const entries = /* @__PURE__ */ new Map();
|
|
91
|
+
return {
|
|
92
|
+
get: (key) => {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
for (const [id, entry] of entries) if (entry.expiresAt <= now) entries.delete(id);
|
|
95
|
+
return entries.get(key)?.value;
|
|
96
|
+
},
|
|
97
|
+
set: (key, value) => {
|
|
98
|
+
entries.set(key, {
|
|
99
|
+
expiresAt: Date.now() + ttlMs,
|
|
100
|
+
value
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region ../core/src/aipass/request.ts
|
|
107
|
+
const browserGetHeaders = (cookie, referer) => ({
|
|
108
|
+
accept: "*/*",
|
|
109
|
+
"accept-language": "th-TH,th;q=0.9,en;q=0.8",
|
|
110
|
+
cookie,
|
|
111
|
+
origin: config.origin,
|
|
112
|
+
referer,
|
|
113
|
+
"sec-fetch-dest": "empty",
|
|
114
|
+
"sec-fetch-mode": "cors",
|
|
115
|
+
"sec-fetch-site": "same-origin",
|
|
116
|
+
"user-agent": config.userAgent
|
|
117
|
+
});
|
|
118
|
+
const browserPostHeaders = (cookie, referer, contentType) => ({
|
|
119
|
+
...browserGetHeaders(cookie, referer),
|
|
120
|
+
"content-type": contentType
|
|
121
|
+
});
|
|
122
|
+
/** Headers for a JSON action made from inside one conversation's page. */
|
|
123
|
+
const conversationJsonHeaders = (cookie, conversationId) => browserPostHeaders(cookie, `${config.origin}/chat/${conversationId}`, "application/json");
|
|
124
|
+
const loadJson = async (path, schema, cookie, signal) => {
|
|
125
|
+
let payload;
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(`${config.origin}${path}`, {
|
|
128
|
+
headers: browserGetHeaders(cookie, `${config.origin}/chat`),
|
|
129
|
+
redirect: "manual",
|
|
130
|
+
signal
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) return null;
|
|
133
|
+
payload = await response.json();
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const decoded = schema.safeParse(payload);
|
|
138
|
+
return decoded.success ? decoded.data : null;
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region ../core/src/aipass/catalog.ts
|
|
142
|
+
const CATALOG_PATH = "/loaders/list-models";
|
|
143
|
+
const CACHE_TTL_MS = 3e5;
|
|
144
|
+
const catalogSchema = z.object({ data: z.array(z.object({
|
|
145
|
+
id: z.string(),
|
|
146
|
+
isFreeCredit: z.boolean().optional(),
|
|
147
|
+
ready: z.boolean().optional(),
|
|
148
|
+
thinkingConfig: z.object({ supportedLevels: z.array(z.string()).optional() }).optional()
|
|
149
|
+
})) });
|
|
150
|
+
const cache = ttlCache(CACHE_TTL_MS);
|
|
151
|
+
const knownChat = new Set(CHAT_MODELS);
|
|
152
|
+
const served = /* @__PURE__ */ new Set([...CHAT_MODELS, ...MEDIA_MODELS]);
|
|
153
|
+
const MEDIA_ROUTES = {
|
|
154
|
+
image: "/v1/images/generations",
|
|
155
|
+
music: "/v1/audio/generations",
|
|
156
|
+
video: "/v1/videos"
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Why a chat request may not name the model, or null when it may. The
|
|
160
|
+
* account's catalog decides: an id upstream added after the build passes,
|
|
161
|
+
* and one upstream retired does not. When the catalog cannot be read the
|
|
162
|
+
* built-in list stands in rather than refusing everything.
|
|
163
|
+
*/
|
|
164
|
+
const chatModelProblem = (model, catalog) => {
|
|
165
|
+
const kind = kindOf(model);
|
|
166
|
+
if (kind !== "chat") return `${model} makes ${kind}, send it to ${MEDIA_ROUTES[kind]}`;
|
|
167
|
+
if (catalog) return catalog.has(model) ? null : `unknown model ${model}, the account's catalog does not list it, see GET /v1/models`;
|
|
168
|
+
return knownChat.has(model) ? null : `unknown model ${model}, see GET /v1/models`;
|
|
169
|
+
};
|
|
170
|
+
const reportDrift = (catalog) => {
|
|
171
|
+
const unknown = [...catalog.keys()].filter((id) => !served.has(id));
|
|
172
|
+
if (unknown.length > 0) config.logger?.warn({
|
|
173
|
+
models: unknown.join(", "),
|
|
174
|
+
msg: "catalog has unserved models"
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
const load = async (cookie, signal) => {
|
|
178
|
+
const listed = await loadJson(CATALOG_PATH, catalogSchema, cookie, signal);
|
|
179
|
+
if (!listed) return null;
|
|
180
|
+
return new Map(listed.data.map((entry) => [entry.id, {
|
|
181
|
+
free: entry.isFreeCredit ?? false,
|
|
182
|
+
ready: entry.ready ?? true,
|
|
183
|
+
thinking: entry.thinkingConfig?.supportedLevels ?? null
|
|
184
|
+
}]));
|
|
185
|
+
};
|
|
186
|
+
const fetchCatalog = async (clientId, cookie, signal) => {
|
|
187
|
+
const cached = cache.get(clientId);
|
|
188
|
+
if (cached) return cached;
|
|
189
|
+
const catalog = await load(cookie, signal);
|
|
190
|
+
if (!catalog) return null;
|
|
191
|
+
cache.set(clientId, catalog);
|
|
192
|
+
reportDrift(catalog);
|
|
193
|
+
return catalog;
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region ../core/src/aipass/refusal.ts
|
|
197
|
+
/**
|
|
198
|
+
* The AI Pass edge answers some requests itself with a 403 before the chat
|
|
199
|
+
* backend runs, scoring what the prompt contains rather than its length. A
|
|
200
|
+
* retry of the same body gets the same verdict, so the proxy reports it as a
|
|
201
|
+
* request error and hands back the edge's own body, since the trigger set is
|
|
202
|
+
* undocumented.
|
|
203
|
+
*/
|
|
204
|
+
const EDGE_REFUSAL_STATUS = 403;
|
|
205
|
+
const EDGE_REFUSAL_HINT = "; the AI Pass edge refused this before the model ran, on what the prompt contains rather than how long it is — resending the same text will be refused again";
|
|
206
|
+
const isEdgeRefusal = (status) => status === EDGE_REFUSAL_STATUS;
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region ../core/src/aipass/upload.ts
|
|
209
|
+
/**
|
|
210
|
+
* Three calls: `initiate` reserves a place and answers with a signed bucket
|
|
211
|
+
* URL, the bytes go there with a plain PUT and no cookie, and `confirm` tells
|
|
212
|
+
* AI Pass the object landed. Only the storage key travels on the message.
|
|
213
|
+
*/
|
|
214
|
+
const INITIATE_PATH = "/actions/upload-file/initiate";
|
|
215
|
+
const CONFIRM_PATH = "/actions/upload-file/confirm";
|
|
216
|
+
/** What the upstream composer accepts. */
|
|
217
|
+
const MAX_ATTACHMENT_BYTES = 20971520;
|
|
218
|
+
const initiateSchema = z.object({
|
|
219
|
+
error: z.string().optional(),
|
|
220
|
+
sizeBytes: z.number().optional(),
|
|
221
|
+
storageKey: z.string(),
|
|
222
|
+
uploadToken: z.string(),
|
|
223
|
+
uploadUrl: z.string()
|
|
224
|
+
});
|
|
225
|
+
const confirmSchema = z.object({
|
|
226
|
+
error: z.string().optional(),
|
|
227
|
+
storageKey: z.string().optional()
|
|
228
|
+
});
|
|
229
|
+
var UploadError = class extends Error {
|
|
230
|
+
name = "UploadError";
|
|
231
|
+
};
|
|
232
|
+
const readJson = async (response, schema, step) => {
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const detail = await response.text().catch(() => "");
|
|
235
|
+
throw new UploadError(`upload ${step} returned ${response.status}: ${detail.slice(0, 200)}`);
|
|
236
|
+
}
|
|
237
|
+
const parsed = schema.safeParse(await response.json().catch(() => null));
|
|
238
|
+
if (!parsed.success) throw new UploadError(`upload ${step} answered in an unreadable shape`);
|
|
239
|
+
return parsed.data;
|
|
240
|
+
};
|
|
241
|
+
/** The bucket answers 412 when a retry re-sends bytes that already landed. */
|
|
242
|
+
const PRECONDITION_FAILED = 412;
|
|
243
|
+
const uploadAttachment = async (cookie, conversationId, modelId, attachment, signal) => {
|
|
244
|
+
const { bytes, filename, mediaType } = attachment;
|
|
245
|
+
if (bytes.length > 20971520) throw new UploadError(`${filename} is ${bytes.length} bytes, over the ${MAX_ATTACHMENT_BYTES} byte limit`);
|
|
246
|
+
const initiated = await readJson(await fetch(`${config.origin}${INITIATE_PATH}`, {
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
contentFilename: filename,
|
|
249
|
+
contentType: mediaType,
|
|
250
|
+
conversationId,
|
|
251
|
+
filename,
|
|
252
|
+
modelId,
|
|
253
|
+
sizeBytes: bytes.length
|
|
254
|
+
}),
|
|
255
|
+
headers: conversationJsonHeaders(cookie, conversationId),
|
|
256
|
+
method: "POST",
|
|
257
|
+
redirect: "manual",
|
|
258
|
+
signal
|
|
259
|
+
}), initiateSchema, "initiate");
|
|
260
|
+
if (initiated.error) throw new UploadError(`upload initiate refused: ${initiated.error}`);
|
|
261
|
+
const putHeaders = { "content-type": mediaType };
|
|
262
|
+
if (initiated.sizeBytes !== void 0) {
|
|
263
|
+
putHeaders["x-goog-content-length-range"] = `${initiated.sizeBytes},${initiated.sizeBytes}`;
|
|
264
|
+
putHeaders["x-goog-if-generation-match"] = "0";
|
|
265
|
+
}
|
|
266
|
+
const put = await fetch(initiated.uploadUrl, {
|
|
267
|
+
body: new Blob([bytes], { type: mediaType }),
|
|
268
|
+
headers: new Headers(Object.entries(putHeaders)),
|
|
269
|
+
method: "PUT",
|
|
270
|
+
signal
|
|
271
|
+
});
|
|
272
|
+
await put.arrayBuffer().catch(() => /* @__PURE__ */ new ArrayBuffer(0));
|
|
273
|
+
if (!put.ok && put.status !== PRECONDITION_FAILED) throw new UploadError(`upload PUT returned ${put.status}`);
|
|
274
|
+
const confirmed = await readJson(await fetch(`${config.origin}${CONFIRM_PATH}`, {
|
|
275
|
+
body: JSON.stringify({ uploadToken: initiated.uploadToken }),
|
|
276
|
+
headers: conversationJsonHeaders(cookie, conversationId),
|
|
277
|
+
method: "POST",
|
|
278
|
+
redirect: "manual",
|
|
279
|
+
signal
|
|
280
|
+
}), confirmSchema, "confirm");
|
|
281
|
+
if (confirmed.error) throw new UploadError(`upload confirm refused: ${confirmed.error}`);
|
|
282
|
+
return {
|
|
283
|
+
filename,
|
|
284
|
+
mediaType,
|
|
285
|
+
storageKey: confirmed.storageKey ?? initiated.storageKey
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region ../core/src/aipass/client.ts
|
|
290
|
+
const CONVERSATION_ID_LENGTH = 16;
|
|
291
|
+
const TITLE_PREVIEW_LENGTH = 400;
|
|
292
|
+
const postAction = (cookie, path, referer, fields, signal) => fetch(`${config.origin}${path}`, {
|
|
293
|
+
body: new URLSearchParams(fields).toString(),
|
|
294
|
+
headers: browserPostHeaders(cookie, referer, "application/x-www-form-urlencoded;charset=UTF-8"),
|
|
295
|
+
method: "POST",
|
|
296
|
+
redirect: "manual",
|
|
297
|
+
signal
|
|
298
|
+
});
|
|
299
|
+
/** Frees the socket for a response nobody is going to read. */
|
|
300
|
+
const drain = async (response) => {
|
|
301
|
+
await response.arrayBuffer().catch(() => /* @__PURE__ */ new ArrayBuffer(0));
|
|
302
|
+
return response.status;
|
|
303
|
+
};
|
|
304
|
+
const createConversation = async (cookie, reqUuid, modelId, text, signal) => {
|
|
305
|
+
const title = text.slice(0, TITLE_PREVIEW_LENGTH) || "hi";
|
|
306
|
+
const response = await postAction(cookie, "/chat.data", `${config.origin}/chat`, {
|
|
307
|
+
clientCreateRequestId: reqUuid,
|
|
308
|
+
folderId: "",
|
|
309
|
+
intent: "create-conversation",
|
|
310
|
+
message: title,
|
|
311
|
+
modelId
|
|
312
|
+
}, signal);
|
|
313
|
+
const conversationId = reqUuid.replaceAll("-", "").slice(0, CONVERSATION_ID_LENGTH);
|
|
314
|
+
/**
|
|
315
|
+
* The title carries the head of the prompt, so the edge can refuse the
|
|
316
|
+
* conversation here before any turn is sent.
|
|
317
|
+
*/
|
|
318
|
+
if (isEdgeRefusal(response.status)) return {
|
|
319
|
+
conversationId,
|
|
320
|
+
refusal: response
|
|
321
|
+
};
|
|
322
|
+
await drain(response);
|
|
323
|
+
return {
|
|
324
|
+
conversationId,
|
|
325
|
+
refusal: null
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
const deleteConversation = async (cookie, conversationId) => {
|
|
329
|
+
try {
|
|
330
|
+
const status = await drain(await postAction(cookie, "/actions/update-conversation.data", `${config.origin}/chat/${conversationId}`, {
|
|
331
|
+
conversationId,
|
|
332
|
+
intent: "delete"
|
|
333
|
+
}));
|
|
334
|
+
if (status >= 400) config.logger?.warn({
|
|
335
|
+
conversationId,
|
|
336
|
+
msg: "delete rejected",
|
|
337
|
+
status
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
config.logger?.warn({
|
|
341
|
+
conversationId,
|
|
342
|
+
err: String(error),
|
|
343
|
+
msg: "delete failed"
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const sendBody = (modelId, messages, options) => {
|
|
348
|
+
const body = {
|
|
349
|
+
messages,
|
|
350
|
+
modelId
|
|
351
|
+
};
|
|
352
|
+
if (options.imageAspectRatio) body.imageAspectRatio = options.imageAspectRatio;
|
|
353
|
+
if (options.thinkingLevel) body.thinkingLevel = options.thinkingLevel;
|
|
354
|
+
return body;
|
|
355
|
+
};
|
|
356
|
+
const lastText = (messages) => messages.at(-1)?.parts.find((part) => part.type === "text")?.text ?? "";
|
|
357
|
+
/** The composer puts attachments above the prompt, so files go first. */
|
|
358
|
+
const withFiles = (messages, files) => messages.map((message, index) => index === messages.length - 1 ? {
|
|
359
|
+
...message,
|
|
360
|
+
parts: [...files, ...message.parts]
|
|
361
|
+
} : message);
|
|
362
|
+
const sendMessage = async (cookie, modelId, messages, signal, options = {}) => {
|
|
363
|
+
const { conversationId, refusal } = await createConversation(cookie, crypto.randomUUID(), modelId, lastText(messages), signal);
|
|
364
|
+
if (refusal) return {
|
|
365
|
+
conversationId,
|
|
366
|
+
created: false,
|
|
367
|
+
response: refusal
|
|
368
|
+
};
|
|
369
|
+
const uploaded = await Promise.all((options.attachments ?? []).map(async (attachment) => {
|
|
370
|
+
const file = await uploadAttachment(cookie, conversationId, modelId, attachment, signal);
|
|
371
|
+
return {
|
|
372
|
+
filename: file.filename,
|
|
373
|
+
mediaType: file.mediaType,
|
|
374
|
+
storageKey: file.storageKey,
|
|
375
|
+
type: "file",
|
|
376
|
+
url: file.storageKey
|
|
377
|
+
};
|
|
378
|
+
}));
|
|
379
|
+
const sent = uploaded.length > 0 ? withFiles(messages, uploaded) : messages;
|
|
380
|
+
return {
|
|
381
|
+
conversationId,
|
|
382
|
+
created: true,
|
|
383
|
+
response: await fetch(`${config.origin}/actions/send-message/${conversationId}`, {
|
|
384
|
+
body: JSON.stringify(sendBody(modelId, sent, options)),
|
|
385
|
+
headers: conversationJsonHeaders(cookie, conversationId),
|
|
386
|
+
method: "POST",
|
|
387
|
+
redirect: "manual",
|
|
388
|
+
signal
|
|
389
|
+
})
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region ../core/src/aipass/media.ts
|
|
394
|
+
const INLINE_CAP = {
|
|
395
|
+
audio: 26214400,
|
|
396
|
+
file: 10485760,
|
|
397
|
+
image: 5242880,
|
|
398
|
+
video: 52428800
|
|
399
|
+
};
|
|
400
|
+
/** Music carries `url`; video carries only `snapshotUrl`, so both are read. */
|
|
401
|
+
const fileEventSchema = z.object({
|
|
402
|
+
filename: z.string().optional(),
|
|
403
|
+
mediaType: z.string().optional(),
|
|
404
|
+
snapshotUrl: z.string().optional(),
|
|
405
|
+
url: z.string().optional()
|
|
406
|
+
});
|
|
407
|
+
const kindOfMediaType = (mediaType) => {
|
|
408
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
409
|
+
if (mediaType.startsWith("video/")) return "video";
|
|
410
|
+
return mediaType.startsWith("audio/") ? "audio" : "file";
|
|
411
|
+
};
|
|
412
|
+
const EXTENSIONS = {
|
|
413
|
+
audio: "mp3",
|
|
414
|
+
file: "bin",
|
|
415
|
+
image: "png",
|
|
416
|
+
video: "mp4"
|
|
417
|
+
};
|
|
418
|
+
const nameFor$1 = (kind, mediaType) => {
|
|
419
|
+
return `${kind}.${mediaType.split("/")[1]?.split(";")[0] ?? EXTENSIONS[kind]}`;
|
|
420
|
+
};
|
|
421
|
+
const toDataUri = (bytes, mediaType) => `data:${mediaType};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
422
|
+
const isAbsolute = (url) => /^https?:\/\//iu.test(url);
|
|
423
|
+
const inlineBase64 = (asset) => asset.href.slice(asset.href.indexOf(",") + 1);
|
|
424
|
+
const renderAsset = (asset) => {
|
|
425
|
+
const link = asset.kind === "image" ? "!" : "";
|
|
426
|
+
const note = asset.note ? `\n\n_${asset.note}_` : "";
|
|
427
|
+
return `\n\n${link}[${asset.filename}](${asset.href})${note}\n\n`;
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* Only a same-origin asset is fetched; an absolute URL elsewhere is public
|
|
431
|
+
* or needs its own credential, and is not this proxy's to get.
|
|
432
|
+
*/
|
|
433
|
+
const resolveAsset = async (cookie, event, signal) => {
|
|
434
|
+
const url = event.url ?? event.snapshotUrl;
|
|
435
|
+
if (!url) return null;
|
|
436
|
+
const mediaType = event.mediaType ?? "application/octet-stream";
|
|
437
|
+
const kind = kindOfMediaType(mediaType);
|
|
438
|
+
const base = {
|
|
439
|
+
filename: event.filename ?? nameFor$1(kind, mediaType),
|
|
440
|
+
kind,
|
|
441
|
+
mediaType
|
|
442
|
+
};
|
|
443
|
+
if (url.startsWith("data:")) return {
|
|
444
|
+
...base,
|
|
445
|
+
href: url,
|
|
446
|
+
inline: true
|
|
447
|
+
};
|
|
448
|
+
const absolute = new URL(url, config.origin).href;
|
|
449
|
+
if (isAbsolute(url) && !absolute.startsWith(config.origin)) return {
|
|
450
|
+
...base,
|
|
451
|
+
href: absolute,
|
|
452
|
+
inline: false
|
|
453
|
+
};
|
|
454
|
+
const needsLogin = "the file is over the size the proxy will carry inline, so this link needs a logged-in browser";
|
|
455
|
+
try {
|
|
456
|
+
const response = await fetch(absolute, {
|
|
457
|
+
headers: browserGetHeaders(cookie, `${config.origin}/chat`),
|
|
458
|
+
redirect: "manual",
|
|
459
|
+
signal
|
|
460
|
+
});
|
|
461
|
+
if (!response.ok) return {
|
|
462
|
+
...base,
|
|
463
|
+
href: absolute,
|
|
464
|
+
inline: false,
|
|
465
|
+
note: `the proxy could not read the file (${response.status}); this link needs a logged-in browser`
|
|
466
|
+
};
|
|
467
|
+
const bytes = await response.arrayBuffer();
|
|
468
|
+
if (bytes.byteLength > INLINE_CAP[kind]) return {
|
|
469
|
+
...base,
|
|
470
|
+
href: absolute,
|
|
471
|
+
inline: false,
|
|
472
|
+
note: needsLogin
|
|
473
|
+
};
|
|
474
|
+
return {
|
|
475
|
+
...base,
|
|
476
|
+
href: toDataUri(bytes, mediaType),
|
|
477
|
+
inline: true
|
|
478
|
+
};
|
|
479
|
+
} catch (error) {
|
|
480
|
+
return {
|
|
481
|
+
...base,
|
|
482
|
+
href: absolute,
|
|
483
|
+
inline: false,
|
|
484
|
+
note: `the proxy could not read the file (${error}); this link needs a logged-in browser`
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region ../core/src/aipass/quotas.ts
|
|
490
|
+
const QUOTA_PATH = "/loaders/get-usage-quota";
|
|
491
|
+
const SETTLE_TIMEOUT_MS = 5e3;
|
|
492
|
+
const amountSchema = z.union([z.string(), z.number()]);
|
|
493
|
+
const quotaResponseSchema = z.object({ creditStatus: z.object({
|
|
494
|
+
credits: z.object({
|
|
495
|
+
available: amountSchema,
|
|
496
|
+
limit: amountSchema,
|
|
497
|
+
used: amountSchema
|
|
498
|
+
}),
|
|
499
|
+
creditsDecimals: z.number().int().min(0).max(18),
|
|
500
|
+
periodEndsAt: z.string()
|
|
501
|
+
}) });
|
|
502
|
+
const fetchCredits = async (cookie, signal) => {
|
|
503
|
+
const quota = await loadJson(QUOTA_PATH, quotaResponseSchema, cookie, signal);
|
|
504
|
+
if (!quota) return null;
|
|
505
|
+
const { credits, creditsDecimals, periodEndsAt } = quota.creditStatus;
|
|
506
|
+
const scale = 10 ** creditsDecimals;
|
|
507
|
+
return {
|
|
508
|
+
creditsAvailable: Number(credits.available) / scale,
|
|
509
|
+
creditsLimit: Number(credits.limit) / scale,
|
|
510
|
+
creditsResetAt: periodEndsAt,
|
|
511
|
+
creditsUsed: Number(credits.used) / scale
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
z.object({
|
|
515
|
+
available: z.number(),
|
|
516
|
+
limit: z.number(),
|
|
517
|
+
reset_at: z.string(),
|
|
518
|
+
used: z.number()
|
|
519
|
+
}).extend({ spent: z.number().optional() });
|
|
520
|
+
const toCreditBalance = (credits) => ({
|
|
521
|
+
available: credits.creditsAvailable,
|
|
522
|
+
limit: credits.creditsLimit,
|
|
523
|
+
reset_at: credits.creditsResetAt,
|
|
524
|
+
used: credits.creditsUsed
|
|
525
|
+
});
|
|
526
|
+
const toCreditUsage = (before, after) => {
|
|
527
|
+
const latest = after ?? before;
|
|
528
|
+
if (!latest) return;
|
|
529
|
+
const balance = toCreditBalance(latest);
|
|
530
|
+
if (!(before && after)) return balance;
|
|
531
|
+
return {
|
|
532
|
+
...balance,
|
|
533
|
+
spent: Math.max(0, after.creditsUsed - before.creditsUsed)
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
/** The read has its own timeout because the request signal may already be aborted. */
|
|
537
|
+
const settleCredits = async (cookie, before) => {
|
|
538
|
+
const [start, after] = await Promise.all([before, fetchCredits(cookie, AbortSignal.timeout(SETTLE_TIMEOUT_MS))]);
|
|
539
|
+
return {
|
|
540
|
+
after,
|
|
541
|
+
usage: toCreditUsage(start, after)
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
const CLIENT_ID_LENGTH = 16;
|
|
545
|
+
const clientIdFromCookie = (cookie) => createHash("sha256").update(cookie).digest("hex").slice(0, CLIENT_ID_LENGTH);
|
|
546
|
+
const thinkingLevelSchema = z.enum([
|
|
547
|
+
"low",
|
|
548
|
+
"medium",
|
|
549
|
+
"high",
|
|
550
|
+
"max"
|
|
551
|
+
]);
|
|
552
|
+
/** The levels to assume for a model whose catalog entry could not be read. */
|
|
553
|
+
const COMMON_LEVELS = [
|
|
554
|
+
"low",
|
|
555
|
+
"medium",
|
|
556
|
+
"high"
|
|
557
|
+
];
|
|
558
|
+
/**
|
|
559
|
+
* An unsupported level is dropped and named, like any other unsupported
|
|
560
|
+
* setting. An unread catalog falls back to the common three so a cold cache
|
|
561
|
+
* does not strip a level the model would have taken.
|
|
562
|
+
*/
|
|
563
|
+
const resolveThinking = (asked, entry) => {
|
|
564
|
+
if (asked === void 0) return { level: null };
|
|
565
|
+
const advertised = entry?.thinking;
|
|
566
|
+
const allowed = advertised && advertised.length > 0 ? advertised : COMMON_LEVELS;
|
|
567
|
+
if (allowed.includes(asked)) return { level: asked };
|
|
568
|
+
return {
|
|
569
|
+
dropped: `the model does not offer thinking level ${asked}, it offers ${allowed.join(", ")}`,
|
|
570
|
+
level: null
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region ../core/src/lib/stream.ts
|
|
575
|
+
const guardController = (controller) => {
|
|
576
|
+
let open = true;
|
|
577
|
+
const attempt = (action) => {
|
|
578
|
+
if (!open) return;
|
|
579
|
+
try {
|
|
580
|
+
action();
|
|
581
|
+
} catch {
|
|
582
|
+
open = false;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
abandon: () => {
|
|
587
|
+
open = false;
|
|
588
|
+
},
|
|
589
|
+
close: () => attempt(() => controller.close()),
|
|
590
|
+
enqueue: (chunk) => attempt(() => controller.enqueue(chunk)),
|
|
591
|
+
isOpen: () => open
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region ../core/src/aipass/inline.ts
|
|
596
|
+
/**
|
|
597
|
+
* A remote URL is refused rather than fetched: a deployment that fetches any
|
|
598
|
+
* URL a caller names is a request forger for the network it sits in.
|
|
599
|
+
*/
|
|
600
|
+
const DATA_URI = /^data:(?<mediaType>[^;,]*)(?<base64>;base64)?,/iu;
|
|
601
|
+
var InlineError = class extends Error {
|
|
602
|
+
name = "InlineError";
|
|
603
|
+
};
|
|
604
|
+
/** atob yields one byte per code unit, so every code point here is below 256. */
|
|
605
|
+
const decodeBase64 = (value) => {
|
|
606
|
+
const binary = atob(value);
|
|
607
|
+
const bytes = new Uint8Array(binary.length);
|
|
608
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.codePointAt(index) ?? 0;
|
|
609
|
+
return bytes;
|
|
610
|
+
};
|
|
611
|
+
const fromBase64 = (data, mediaType, filename) => {
|
|
612
|
+
try {
|
|
613
|
+
return {
|
|
614
|
+
bytes: decodeBase64(data),
|
|
615
|
+
filename,
|
|
616
|
+
mediaType
|
|
617
|
+
};
|
|
618
|
+
} catch {
|
|
619
|
+
throw new InlineError(`${filename} is not readable base64`);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
const nameFor = (mediaType, index) => {
|
|
623
|
+
const subtype = mediaType.split("/")[1]?.split(";")[0] ?? "bin";
|
|
624
|
+
const extension = subtype === "jpeg" ? "jpg" : subtype;
|
|
625
|
+
return `${mediaType.startsWith("image/") ? "image" : "attachment"}-${index + 1}.${extension}`;
|
|
626
|
+
};
|
|
627
|
+
/** Percent-encoded data URIs are decoded too; small SVG and text files arrive that way. */
|
|
628
|
+
const fromDataUri = (uri, filename, index = 0) => {
|
|
629
|
+
const match = DATA_URI.exec(uri);
|
|
630
|
+
if (!match) throw new InlineError("only inline data is accepted; send the file as a data: URI or base64, not as a URL the proxy would have to fetch");
|
|
631
|
+
const mediaType = match.groups?.mediaType || "application/octet-stream";
|
|
632
|
+
const payload = uri.slice(match[0].length);
|
|
633
|
+
const name = filename ?? nameFor(mediaType, index);
|
|
634
|
+
if (match.groups?.base64) return fromBase64(payload, mediaType, name);
|
|
635
|
+
return {
|
|
636
|
+
bytes: new TextEncoder().encode(decodeURIComponent(payload)),
|
|
637
|
+
filename: name,
|
|
638
|
+
mediaType
|
|
639
|
+
};
|
|
640
|
+
};
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region ../core/src/aipass/stream.ts
|
|
643
|
+
const optionalText = z.union([z.string(), z.unknown().transform(() => void 0)]).optional();
|
|
644
|
+
/** Some models put the file's fields at the top level, others under `data`; the outer wins. */
|
|
645
|
+
const fileFrameSchema = fileEventSchema.extend({
|
|
646
|
+
data: fileEventSchema.optional(),
|
|
647
|
+
type: z.literal("file")
|
|
648
|
+
});
|
|
649
|
+
const upstreamEventSchema = z.discriminatedUnion("type", [
|
|
650
|
+
z.object({
|
|
651
|
+
delta: z.string(),
|
|
652
|
+
type: z.literal("text-delta")
|
|
653
|
+
}),
|
|
654
|
+
z.object({
|
|
655
|
+
delta: z.string(),
|
|
656
|
+
type: z.literal("reasoning-delta")
|
|
657
|
+
}),
|
|
658
|
+
fileFrameSchema,
|
|
659
|
+
z.object({
|
|
660
|
+
finishReason: optionalText,
|
|
661
|
+
type: z.literal("finish")
|
|
662
|
+
}),
|
|
663
|
+
z.object({
|
|
664
|
+
error: optionalText,
|
|
665
|
+
errorText: optionalText,
|
|
666
|
+
type: z.literal("error")
|
|
667
|
+
})
|
|
668
|
+
]);
|
|
669
|
+
const skippedTypeSchema = z.object({ type: z.string() });
|
|
670
|
+
const parseAipassSSE = async function* parseAipassSSE(body, skips) {
|
|
671
|
+
const chunks = parseJsonEventStream({
|
|
672
|
+
schema: upstreamEventSchema,
|
|
673
|
+
stream: body
|
|
674
|
+
});
|
|
675
|
+
for await (const chunk of chunks) {
|
|
676
|
+
if (!chunk.success) {
|
|
677
|
+
if (skips) {
|
|
678
|
+
skips.count += 1;
|
|
679
|
+
const named = skippedTypeSchema.safeParse(chunk.rawValue);
|
|
680
|
+
if (named.success) skips.types.add(named.data.type);
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const event = chunk.value;
|
|
685
|
+
switch (event.type) {
|
|
686
|
+
case "text-delta":
|
|
687
|
+
yield {
|
|
688
|
+
kind: "delta",
|
|
689
|
+
text: event.delta
|
|
690
|
+
};
|
|
691
|
+
break;
|
|
692
|
+
case "reasoning-delta":
|
|
693
|
+
yield {
|
|
694
|
+
kind: "reasoning",
|
|
695
|
+
text: event.delta
|
|
696
|
+
};
|
|
697
|
+
break;
|
|
698
|
+
case "file": {
|
|
699
|
+
const { data } = event;
|
|
700
|
+
yield {
|
|
701
|
+
file: {
|
|
702
|
+
filename: event.filename ?? data?.filename,
|
|
703
|
+
mediaType: event.mediaType ?? data?.mediaType,
|
|
704
|
+
snapshotUrl: event.snapshotUrl ?? data?.snapshotUrl,
|
|
705
|
+
url: event.url ?? data?.url
|
|
706
|
+
},
|
|
707
|
+
kind: "file"
|
|
708
|
+
};
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case "finish":
|
|
712
|
+
yield {
|
|
713
|
+
kind: "finish",
|
|
714
|
+
reason: event.finishReason ?? "stop"
|
|
715
|
+
};
|
|
716
|
+
break;
|
|
717
|
+
default: yield {
|
|
718
|
+
kind: "error",
|
|
719
|
+
message: event.errorText ?? event.error ?? "upstream error"
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region ../core/src/tokens.ts
|
|
726
|
+
/**
|
|
727
|
+
* AI Pass reports no token counts. ASCII runs about four characters per
|
|
728
|
+
* token; Thai, other non-Latin scripts and emoji nearer one and a half, so
|
|
729
|
+
* the two are counted apart.
|
|
730
|
+
*/
|
|
731
|
+
const ASCII = /[\u0020-\u007E\s]/gu;
|
|
732
|
+
const ASCII_CHARS_PER_TOKEN = 4;
|
|
733
|
+
const OTHER_CHARS_PER_TOKEN = 1.5;
|
|
734
|
+
const newCharCount = () => ({
|
|
735
|
+
ascii: 0,
|
|
736
|
+
other: 0
|
|
737
|
+
});
|
|
738
|
+
const addText = (count, text) => {
|
|
739
|
+
const ascii = text.match(ASCII)?.length ?? 0;
|
|
740
|
+
count.ascii += ascii;
|
|
741
|
+
count.other += text.length - ascii;
|
|
742
|
+
};
|
|
743
|
+
const charTokens = (count) => Math.ceil(count.ascii / ASCII_CHARS_PER_TOKEN + count.other / OTHER_CHARS_PER_TOKEN);
|
|
744
|
+
const estimateTokens = (text) => {
|
|
745
|
+
const count = newCharCount();
|
|
746
|
+
addText(count, text);
|
|
747
|
+
return charTokens(count);
|
|
748
|
+
};
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region ../core/src/lib/id.ts
|
|
751
|
+
/** The first `length` hex characters of a random UUID, for wire-visible ids. */
|
|
752
|
+
const randomHex = (length) => crypto.randomUUID().replaceAll("-", "").slice(0, length);
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region ../core/src/tools.ts
|
|
755
|
+
const toolInputSchema = z.record(z.string(), z.unknown());
|
|
756
|
+
const FENCE_OPEN = "```tool_call\n";
|
|
757
|
+
const FENCE_CLOSE = "\n```";
|
|
758
|
+
const CALL_ID_LENGTH = 24;
|
|
759
|
+
const callBodySchema = z.object({
|
|
760
|
+
input: toolInputSchema.default({}),
|
|
761
|
+
name: z.string().min(1)
|
|
762
|
+
});
|
|
763
|
+
const text = (value) => value.length > 0 ? [{
|
|
764
|
+
text: value,
|
|
765
|
+
type: "text"
|
|
766
|
+
}] : [];
|
|
767
|
+
const newCallId = () => randomHex(CALL_ID_LENGTH);
|
|
768
|
+
const renderCall = (call) => `${FENCE_OPEN}${JSON.stringify({
|
|
769
|
+
input: call.input,
|
|
770
|
+
name: call.name
|
|
771
|
+
})}${FENCE_CLOSE}`;
|
|
772
|
+
const renderTool = (tool) => {
|
|
773
|
+
const summary = tool.description && tool.description.length > 0 ? `- ${tool.name}: ${tool.description.trim()}` : `- ${tool.name}`;
|
|
774
|
+
return tool.inputSchema === void 0 ? summary : `${summary}\n input schema: ${JSON.stringify(tool.inputSchema)}`;
|
|
775
|
+
};
|
|
776
|
+
const renderToolGuide = (tools) => [
|
|
777
|
+
"You can call tools. To call one, write a block exactly like this, one block per call, then stop and wait for the result, which arrives in the next turn:",
|
|
778
|
+
`${FENCE_OPEN}{"name": "<tool name>", "input": {<arguments matching the tool's input schema>}}${FENCE_CLOSE}`,
|
|
779
|
+
`Tools:\n\n${tools.map(renderTool).join("\n")}`
|
|
780
|
+
].join("\n\n");
|
|
781
|
+
const parseCall = (json, names) => {
|
|
782
|
+
let raw;
|
|
783
|
+
try {
|
|
784
|
+
raw = JSON.parse(json);
|
|
785
|
+
} catch {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const parsed = callBodySchema.safeParse(raw);
|
|
789
|
+
if (!parsed.success || !names.has(parsed.data.name)) return;
|
|
790
|
+
return {
|
|
791
|
+
id: newCallId(),
|
|
792
|
+
input: parsed.data.input,
|
|
793
|
+
name: parsed.data.name
|
|
794
|
+
};
|
|
795
|
+
};
|
|
796
|
+
/** How many trailing characters could still turn out to open a fence. */
|
|
797
|
+
const heldLength = (tail) => {
|
|
798
|
+
const longest = Math.min(tail.length, 12);
|
|
799
|
+
for (let length = longest; length > 0; length -= 1) if ("```tool_call\n".startsWith(tail.slice(-length))) return length;
|
|
800
|
+
return 0;
|
|
801
|
+
};
|
|
802
|
+
/**
|
|
803
|
+
* Text is released as soon as it can no longer open a fence. A fence that
|
|
804
|
+
* never closes, or holds no call to an offered tool, is released as text.
|
|
805
|
+
*/
|
|
806
|
+
const splitReply = (tools) => {
|
|
807
|
+
if (tools.length === 0) return {
|
|
808
|
+
flush: () => [],
|
|
809
|
+
push: text
|
|
810
|
+
};
|
|
811
|
+
const names = new Set(tools.map((tool) => tool.name));
|
|
812
|
+
let buffer = "";
|
|
813
|
+
let inBlock = false;
|
|
814
|
+
/** Ends the open block; `fence` is what closed it, nothing when the reply ran out first. */
|
|
815
|
+
const closeBlock = (json, fence) => {
|
|
816
|
+
inBlock = false;
|
|
817
|
+
const call = parseCall(json.trim(), names);
|
|
818
|
+
return call ? [{
|
|
819
|
+
call,
|
|
820
|
+
type: "call"
|
|
821
|
+
}] : text(`${FENCE_OPEN}${json}${fence}`);
|
|
822
|
+
};
|
|
823
|
+
const drain = (final) => {
|
|
824
|
+
const parts = [];
|
|
825
|
+
for (;;) {
|
|
826
|
+
if (inBlock) {
|
|
827
|
+
const end = buffer.indexOf(FENCE_CLOSE);
|
|
828
|
+
if (end === -1) {
|
|
829
|
+
if (!final) return parts;
|
|
830
|
+
const json = buffer;
|
|
831
|
+
buffer = "";
|
|
832
|
+
parts.push(...closeBlock(json, ""));
|
|
833
|
+
return parts;
|
|
834
|
+
}
|
|
835
|
+
const json = buffer.slice(0, end);
|
|
836
|
+
buffer = buffer.slice(end + 4);
|
|
837
|
+
if (buffer.startsWith("\n")) buffer = buffer.slice(1);
|
|
838
|
+
parts.push(...closeBlock(json, FENCE_CLOSE));
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const start = buffer.indexOf(FENCE_OPEN);
|
|
842
|
+
if (start === -1) {
|
|
843
|
+
const hold = final ? 0 : heldLength(buffer);
|
|
844
|
+
const released = buffer.slice(0, buffer.length - hold);
|
|
845
|
+
buffer = buffer.slice(buffer.length - hold);
|
|
846
|
+
parts.push(...text(released));
|
|
847
|
+
return parts;
|
|
848
|
+
}
|
|
849
|
+
parts.push(...text(buffer.slice(0, start)));
|
|
850
|
+
buffer = buffer.slice(start + 13);
|
|
851
|
+
inBlock = true;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
return {
|
|
855
|
+
flush: () => drain(true),
|
|
856
|
+
push: (chunk) => {
|
|
857
|
+
buffer += chunk;
|
|
858
|
+
return drain(false);
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
};
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region ../core/src/translate.ts
|
|
864
|
+
const filesOf = (conversation) => conversation.turns.flatMap((turn) => turn.files ?? []);
|
|
865
|
+
const toolNames = (turns) => {
|
|
866
|
+
const names = /* @__PURE__ */ new Map();
|
|
867
|
+
for (const turn of turns) for (const call of turn.calls ?? []) names.set(call.id, call.name);
|
|
868
|
+
return names;
|
|
869
|
+
};
|
|
870
|
+
const roleLabel = (turn, names) => {
|
|
871
|
+
if (turn.role === "assistant") return "Assistant";
|
|
872
|
+
if (turn.role === "tool") {
|
|
873
|
+
const name = turn.callId === void 0 ? void 0 : names.get(turn.callId);
|
|
874
|
+
return name === void 0 ? "Tool" : `Tool (${name})`;
|
|
875
|
+
}
|
|
876
|
+
return "User";
|
|
877
|
+
};
|
|
878
|
+
const turnText = (turn) => {
|
|
879
|
+
return [turn.content.trim(), ...(turn.calls ?? []).map(renderCall)].filter((part) => part.length > 0).join("\n\n");
|
|
880
|
+
};
|
|
881
|
+
const flattenPrompt = ({ tools, turns }) => {
|
|
882
|
+
const system = turns.filter((turn) => turn.role === "system").map((turn) => turn.content).filter((text) => text.length > 0).join("\n\n");
|
|
883
|
+
const convo = turns.filter((turn) => turn.role !== "system");
|
|
884
|
+
if (system.length === 0 && tools.length === 0 && convo.length === 1 && convo[0]?.role === "user" && convo[0].calls === void 0) return convo[0].content;
|
|
885
|
+
const blocks = [];
|
|
886
|
+
if (system.length > 0) blocks.push(system.trim());
|
|
887
|
+
if (tools.length > 0) blocks.push(renderToolGuide(tools));
|
|
888
|
+
const names = toolNames(convo);
|
|
889
|
+
const lines = [];
|
|
890
|
+
for (const turn of convo) {
|
|
891
|
+
const text = turnText(turn);
|
|
892
|
+
if (text.length === 0) continue;
|
|
893
|
+
lines.push(`${roleLabel(turn, names)}: ${text}`);
|
|
894
|
+
}
|
|
895
|
+
if (lines.length > 0) blocks.push(lines.join("\n\n"));
|
|
896
|
+
return blocks.join("\n\n");
|
|
897
|
+
};
|
|
898
|
+
const toAipassMessages = (prompt, modelId) => [{
|
|
899
|
+
id: crypto.randomUUID(),
|
|
900
|
+
metadata: { modelId },
|
|
901
|
+
parts: [{
|
|
902
|
+
text: prompt,
|
|
903
|
+
type: "text"
|
|
904
|
+
}],
|
|
905
|
+
role: "user"
|
|
906
|
+
}];
|
|
907
|
+
//#endregion
|
|
908
|
+
//#region ../core/src/reply.ts
|
|
909
|
+
/** Only a request that asked for a level reads the catalog; on a cold cache that is a round trip. */
|
|
910
|
+
const prepareTurn = async (conversation, model, thinking, catalog) => {
|
|
911
|
+
const prompt = flattenPrompt(conversation);
|
|
912
|
+
const inputTokens = estimateTokens(prompt);
|
|
913
|
+
let thinkingLevel = null;
|
|
914
|
+
let thinkingDropped;
|
|
915
|
+
if (thinking !== void 0) {
|
|
916
|
+
const entries = await catalog();
|
|
917
|
+
const resolved = resolveThinking(thinking, entries?.get(model));
|
|
918
|
+
thinkingLevel = resolved.level;
|
|
919
|
+
thinkingDropped = resolved.dropped;
|
|
920
|
+
}
|
|
921
|
+
const sendOptions = { attachments: filesOf(conversation).map((file, index) => fromDataUri(file.uri, file.filename, index)) };
|
|
922
|
+
if (thinkingLevel) sendOptions.thinkingLevel = thinkingLevel;
|
|
923
|
+
return {
|
|
924
|
+
inputTokens,
|
|
925
|
+
prompt,
|
|
926
|
+
sendOptions,
|
|
927
|
+
thinkingDropped,
|
|
928
|
+
thinkingLevel
|
|
929
|
+
};
|
|
930
|
+
};
|
|
931
|
+
const finishReasonOf = (tally) => tally.calls > 0 && tally.finishReason === "stop" ? "tool-calls" : tally.finishReason;
|
|
932
|
+
const replyTokens = (tally) => charTokens(tally.reply);
|
|
933
|
+
/**
|
|
934
|
+
* Text goes through the tool-call splitter, so a fenced call never reaches the
|
|
935
|
+
* caller as text. An upstream error is yielded rather than thrown, since each
|
|
936
|
+
* caller handles one differently.
|
|
937
|
+
*/
|
|
938
|
+
const readReply = (options) => {
|
|
939
|
+
const { body, cookie, signal, tools } = options;
|
|
940
|
+
const startedAt = options.startedAt ?? Date.now();
|
|
941
|
+
const tally = {
|
|
942
|
+
calls: 0,
|
|
943
|
+
chars: 0,
|
|
944
|
+
deltas: 0,
|
|
945
|
+
files: 0,
|
|
946
|
+
finishReason: "stop",
|
|
947
|
+
msToFirstChunk: void 0,
|
|
948
|
+
reasoningChars: 0,
|
|
949
|
+
reply: newCharCount(),
|
|
950
|
+
skips: {
|
|
951
|
+
count: 0,
|
|
952
|
+
types: /* @__PURE__ */ new Set()
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
const splitter = splitReply(tools);
|
|
956
|
+
const fromParts = function* fromParts(parts) {
|
|
957
|
+
for (const part of parts) if (part.type === "text") {
|
|
958
|
+
tally.chars += part.text.length;
|
|
959
|
+
addText(tally.reply, part.text);
|
|
960
|
+
yield {
|
|
961
|
+
kind: "text",
|
|
962
|
+
text: part.text
|
|
963
|
+
};
|
|
964
|
+
} else {
|
|
965
|
+
tally.calls += 1;
|
|
966
|
+
addText(tally.reply, renderCall(part.call));
|
|
967
|
+
yield {
|
|
968
|
+
call: part.call,
|
|
969
|
+
kind: "call"
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
return {
|
|
974
|
+
events: async function* events() {
|
|
975
|
+
for await (const event of parseAipassSSE(body, tally.skips)) if (event.kind === "delta") {
|
|
976
|
+
tally.msToFirstChunk ??= Date.now() - startedAt;
|
|
977
|
+
tally.deltas += 1;
|
|
978
|
+
yield* fromParts(splitter.push(event.text));
|
|
979
|
+
} else if (event.kind === "reasoning") {
|
|
980
|
+
tally.reasoningChars += event.text.length;
|
|
981
|
+
yield {
|
|
982
|
+
kind: "reasoning",
|
|
983
|
+
text: event.text
|
|
984
|
+
};
|
|
985
|
+
} else if (event.kind === "file") {
|
|
986
|
+
const asset = await resolveAsset(cookie, event.file, signal);
|
|
987
|
+
if (asset) {
|
|
988
|
+
tally.files += 1;
|
|
989
|
+
yield {
|
|
990
|
+
asset,
|
|
991
|
+
kind: "file"
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
} else if (event.kind === "finish") tally.finishReason = event.reason;
|
|
995
|
+
else yield {
|
|
996
|
+
kind: "error",
|
|
997
|
+
message: event.message
|
|
998
|
+
};
|
|
999
|
+
yield* fromParts(splitter.flush());
|
|
1000
|
+
}(),
|
|
1001
|
+
tally
|
|
1002
|
+
};
|
|
1003
|
+
};
|
|
1004
|
+
//#endregion
|
|
1005
|
+
//#region src/prompt.ts
|
|
1006
|
+
const resultText = (output) => {
|
|
1007
|
+
switch (output.type) {
|
|
1008
|
+
case "text":
|
|
1009
|
+
case "error-text": return output.value;
|
|
1010
|
+
case "json":
|
|
1011
|
+
case "error-json": return JSON.stringify(output.value);
|
|
1012
|
+
default: return output.value.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
/** The SDK types a call's input as unknown; a call whose input is not an object carries none. */
|
|
1016
|
+
const inputOf = (part) => {
|
|
1017
|
+
const parsed = toolInputSchema.safeParse(part.input);
|
|
1018
|
+
return parsed.success ? parsed.data : {};
|
|
1019
|
+
};
|
|
1020
|
+
const droppedWarning = (dropped) => dropped.size > 0 ? [{
|
|
1021
|
+
message: `the provider dropped ${[...dropped].toSorted().join(", ")} parts because AI Pass reads text only`,
|
|
1022
|
+
type: "other"
|
|
1023
|
+
}] : [];
|
|
1024
|
+
/** Inline bytes become a data URI; a URL is passed through to be refused where every protocol's files decode. */
|
|
1025
|
+
const fileUri = (part) => {
|
|
1026
|
+
const { data, mediaType } = part;
|
|
1027
|
+
if (data instanceof URL) return data.href;
|
|
1028
|
+
if (data instanceof Uint8Array) return `data:${mediaType};base64,${Buffer.from(data).toString("base64")}`;
|
|
1029
|
+
return data.startsWith("data:") ? data : `data:${mediaType};base64,${data}`;
|
|
1030
|
+
};
|
|
1031
|
+
const toTurnFile = (part, index) => ({
|
|
1032
|
+
filename: part.filename ?? nameFor(part.mediaType, index),
|
|
1033
|
+
uri: fileUri(part)
|
|
1034
|
+
});
|
|
1035
|
+
const convertPrompt = (prompt) => {
|
|
1036
|
+
const turns = [];
|
|
1037
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
1038
|
+
for (const message of prompt) {
|
|
1039
|
+
if (message.role === "system") {
|
|
1040
|
+
turns.push({
|
|
1041
|
+
content: message.content,
|
|
1042
|
+
role: "system"
|
|
1043
|
+
});
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (message.role === "tool") {
|
|
1047
|
+
for (const part of message.content) turns.push({
|
|
1048
|
+
callId: part.toolCallId,
|
|
1049
|
+
content: resultText(part.output),
|
|
1050
|
+
role: "tool"
|
|
1051
|
+
});
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const text = [];
|
|
1055
|
+
const calls = [];
|
|
1056
|
+
const files = [];
|
|
1057
|
+
for (const part of message.content) if (part.type === "text" || part.type === "reasoning") text.push(part.text);
|
|
1058
|
+
else if (part.type === "file") files.push(toTurnFile(part, files.length));
|
|
1059
|
+
else if (part.type === "tool-call") calls.push({
|
|
1060
|
+
id: part.toolCallId,
|
|
1061
|
+
input: inputOf(part),
|
|
1062
|
+
name: part.toolName
|
|
1063
|
+
});
|
|
1064
|
+
else dropped.add(part.type);
|
|
1065
|
+
const turn = {
|
|
1066
|
+
content: text.join(""),
|
|
1067
|
+
role: message.role
|
|
1068
|
+
};
|
|
1069
|
+
const withFiles = files.length > 0 ? {
|
|
1070
|
+
...turn,
|
|
1071
|
+
files
|
|
1072
|
+
} : turn;
|
|
1073
|
+
turns.push(calls.length > 0 ? {
|
|
1074
|
+
...withFiles,
|
|
1075
|
+
calls
|
|
1076
|
+
} : withFiles);
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
turns,
|
|
1080
|
+
warnings: droppedWarning(dropped)
|
|
1081
|
+
};
|
|
1082
|
+
};
|
|
1083
|
+
/** Function tools are offered through the text protocol; provider-defined ones have no upstream to run on. */
|
|
1084
|
+
const convertTools = (options) => {
|
|
1085
|
+
const tools = [];
|
|
1086
|
+
const warnings = [];
|
|
1087
|
+
for (const tool of options.tools ?? []) if (tool.type === "function") tools.push({
|
|
1088
|
+
description: tool.description,
|
|
1089
|
+
inputSchema: tool.inputSchema,
|
|
1090
|
+
name: tool.name
|
|
1091
|
+
});
|
|
1092
|
+
else warnings.push({
|
|
1093
|
+
tool,
|
|
1094
|
+
type: "unsupported-tool"
|
|
1095
|
+
});
|
|
1096
|
+
return {
|
|
1097
|
+
tools,
|
|
1098
|
+
warnings
|
|
1099
|
+
};
|
|
1100
|
+
};
|
|
1101
|
+
//#endregion
|
|
1102
|
+
//#region src/model.ts
|
|
1103
|
+
const PROVIDER = "aipass";
|
|
1104
|
+
const REASONING_ID = "reasoning";
|
|
1105
|
+
const DETAIL_LIMIT = 300;
|
|
1106
|
+
const estimatedUsage = (inputTokens, outputTokens) => ({
|
|
1107
|
+
inputTokens,
|
|
1108
|
+
outputTokens,
|
|
1109
|
+
totalTokens: inputTokens + outputTokens
|
|
1110
|
+
});
|
|
1111
|
+
const FINISH_REASONS = /* @__PURE__ */ new Map([
|
|
1112
|
+
["content-filter", "content-filter"],
|
|
1113
|
+
["error", "error"],
|
|
1114
|
+
["length", "length"],
|
|
1115
|
+
["other", "other"],
|
|
1116
|
+
["stop", "stop"],
|
|
1117
|
+
["tool-calls", "tool-calls"],
|
|
1118
|
+
["unknown", "unknown"]
|
|
1119
|
+
]);
|
|
1120
|
+
const UNSUPPORTED_SETTINGS = [
|
|
1121
|
+
"frequencyPenalty",
|
|
1122
|
+
"maxOutputTokens",
|
|
1123
|
+
"presencePenalty",
|
|
1124
|
+
"responseFormat",
|
|
1125
|
+
"seed",
|
|
1126
|
+
"stopSequences",
|
|
1127
|
+
"temperature",
|
|
1128
|
+
"toolChoice",
|
|
1129
|
+
"topK",
|
|
1130
|
+
"topP"
|
|
1131
|
+
];
|
|
1132
|
+
const toFinishReason = (reason) => FINISH_REASONS.get(reason) ?? "unknown";
|
|
1133
|
+
/** AI Pass takes a level, not a token budget, so the SDK's own reasoning settings do not map. */
|
|
1134
|
+
const askedThinking = (options) => {
|
|
1135
|
+
const asked = options.providerOptions?.aipass?.thinkingLevel;
|
|
1136
|
+
const parsed = thinkingLevelSchema.safeParse(asked);
|
|
1137
|
+
return parsed.success ? parsed.data : void 0;
|
|
1138
|
+
};
|
|
1139
|
+
const settingWarnings = (options) => {
|
|
1140
|
+
const warnings = [];
|
|
1141
|
+
for (const setting of UNSUPPORTED_SETTINGS) if (options[setting] !== void 0) warnings.push({
|
|
1142
|
+
setting,
|
|
1143
|
+
type: "unsupported-setting"
|
|
1144
|
+
});
|
|
1145
|
+
return warnings;
|
|
1146
|
+
};
|
|
1147
|
+
/** Provider metadata allows no undefined, so `spent` is left out when unknown. */
|
|
1148
|
+
const creditMetadata = (usage) => {
|
|
1149
|
+
if (!usage) return;
|
|
1150
|
+
const { spent, ...balance } = usage;
|
|
1151
|
+
return { aipass: { credits: spent === void 0 ? balance : {
|
|
1152
|
+
...balance,
|
|
1153
|
+
spent
|
|
1154
|
+
} } };
|
|
1155
|
+
};
|
|
1156
|
+
const toolCallPart = (call) => ({
|
|
1157
|
+
input: JSON.stringify(call.input),
|
|
1158
|
+
toolCallId: call.id,
|
|
1159
|
+
toolName: call.name,
|
|
1160
|
+
type: "tool-call"
|
|
1161
|
+
});
|
|
1162
|
+
const startTurn = async (cookie, modelId, options) => {
|
|
1163
|
+
const prompt = convertPrompt(options.prompt);
|
|
1164
|
+
const offered = convertTools(options);
|
|
1165
|
+
const { tools } = offered;
|
|
1166
|
+
const catalog = fetchCatalog(clientIdFromCookie(cookie), cookie, options.abortSignal);
|
|
1167
|
+
const problem = chatModelProblem(modelId, await catalog);
|
|
1168
|
+
if (problem) throw new NoSuchModelError({
|
|
1169
|
+
message: problem,
|
|
1170
|
+
modelId,
|
|
1171
|
+
modelType: "languageModel"
|
|
1172
|
+
});
|
|
1173
|
+
const credits = fetchCredits(cookie, options.abortSignal);
|
|
1174
|
+
const prepared = await prepareTurn({
|
|
1175
|
+
tools,
|
|
1176
|
+
turns: prompt.turns
|
|
1177
|
+
}, modelId, askedThinking(options), () => catalog);
|
|
1178
|
+
const body = toAipassMessages(prepared.prompt, modelId);
|
|
1179
|
+
const { conversationId, created, response } = await sendMessage(cookie, modelId, body, options.abortSignal, prepared.sendOptions);
|
|
1180
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1181
|
+
if (response.ok && response.body && contentType.includes("event-stream")) {
|
|
1182
|
+
const warnings = [
|
|
1183
|
+
...prompt.warnings,
|
|
1184
|
+
...offered.warnings,
|
|
1185
|
+
...settingWarnings(options)
|
|
1186
|
+
];
|
|
1187
|
+
if (prepared.thinkingDropped) warnings.push({
|
|
1188
|
+
message: prepared.thinkingDropped,
|
|
1189
|
+
type: "other"
|
|
1190
|
+
});
|
|
1191
|
+
return {
|
|
1192
|
+
conversationId,
|
|
1193
|
+
credits,
|
|
1194
|
+
inputTokens: prepared.inputTokens,
|
|
1195
|
+
reader: readReply({
|
|
1196
|
+
body: response.body,
|
|
1197
|
+
cookie,
|
|
1198
|
+
signal: options.abortSignal,
|
|
1199
|
+
tools
|
|
1200
|
+
}),
|
|
1201
|
+
warnings
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const detail = response.body ? await response.text().catch(() => "") : "";
|
|
1205
|
+
if (created) await deleteConversation(cookie, conversationId);
|
|
1206
|
+
const hint = isEdgeRefusal(response.status) ? EDGE_REFUSAL_HINT : "";
|
|
1207
|
+
throw new APICallError({
|
|
1208
|
+
message: `AI Pass answered ${response.status} (${contentType || "no content-type"})${hint}`,
|
|
1209
|
+
requestBodyValues: {
|
|
1210
|
+
messages: body,
|
|
1211
|
+
modelId
|
|
1212
|
+
},
|
|
1213
|
+
responseBody: detail.slice(0, DETAIL_LIMIT),
|
|
1214
|
+
statusCode: response.status,
|
|
1215
|
+
url: created ? `${config.origin}/actions/send-message/${conversationId}` : `${config.origin}/chat.data`
|
|
1216
|
+
});
|
|
1217
|
+
};
|
|
1218
|
+
/** An inline asset becomes the SDK's file part; a link is rendered as text with the reason. */
|
|
1219
|
+
const assetPart = (asset) => {
|
|
1220
|
+
if (!asset.inline) return { text: renderAsset(asset) };
|
|
1221
|
+
return {
|
|
1222
|
+
data: inlineBase64(asset),
|
|
1223
|
+
mediaType: asset.mediaType,
|
|
1224
|
+
type: "file"
|
|
1225
|
+
};
|
|
1226
|
+
};
|
|
1227
|
+
const streamTurn = (cookie, turn) => new ReadableStream({ async start(raw) {
|
|
1228
|
+
const out = guardController(raw);
|
|
1229
|
+
const { reader } = turn;
|
|
1230
|
+
const { tally } = reader;
|
|
1231
|
+
out.enqueue({
|
|
1232
|
+
type: "stream-start",
|
|
1233
|
+
warnings: turn.warnings
|
|
1234
|
+
});
|
|
1235
|
+
let textId;
|
|
1236
|
+
let textBlocks = 0;
|
|
1237
|
+
let reasoningOpen = false;
|
|
1238
|
+
const closeText = () => {
|
|
1239
|
+
if (textId !== void 0) {
|
|
1240
|
+
out.enqueue({
|
|
1241
|
+
id: textId,
|
|
1242
|
+
type: "text-end"
|
|
1243
|
+
});
|
|
1244
|
+
textId = void 0;
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
const text = (delta) => {
|
|
1248
|
+
if (textId === void 0) {
|
|
1249
|
+
textId = `text-${textBlocks}`;
|
|
1250
|
+
textBlocks += 1;
|
|
1251
|
+
out.enqueue({
|
|
1252
|
+
id: textId,
|
|
1253
|
+
type: "text-start"
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
out.enqueue({
|
|
1257
|
+
delta,
|
|
1258
|
+
id: textId,
|
|
1259
|
+
type: "text-delta"
|
|
1260
|
+
});
|
|
1261
|
+
};
|
|
1262
|
+
try {
|
|
1263
|
+
for await (const event of reader.events) if (event.kind === "text") text(event.text);
|
|
1264
|
+
else if (event.kind === "call") {
|
|
1265
|
+
closeText();
|
|
1266
|
+
out.enqueue(toolCallPart(event.call));
|
|
1267
|
+
} else if (event.kind === "reasoning") {
|
|
1268
|
+
if (!reasoningOpen) {
|
|
1269
|
+
out.enqueue({
|
|
1270
|
+
id: REASONING_ID,
|
|
1271
|
+
type: "reasoning-start"
|
|
1272
|
+
});
|
|
1273
|
+
reasoningOpen = true;
|
|
1274
|
+
}
|
|
1275
|
+
out.enqueue({
|
|
1276
|
+
delta: event.text,
|
|
1277
|
+
id: REASONING_ID,
|
|
1278
|
+
type: "reasoning-delta"
|
|
1279
|
+
});
|
|
1280
|
+
} else if (event.kind === "file") {
|
|
1281
|
+
const part = assetPart(event.asset);
|
|
1282
|
+
if ("type" in part) {
|
|
1283
|
+
closeText();
|
|
1284
|
+
out.enqueue(part);
|
|
1285
|
+
} else text(part.text);
|
|
1286
|
+
} else {
|
|
1287
|
+
tally.finishReason = "error";
|
|
1288
|
+
out.enqueue({
|
|
1289
|
+
error: new Error(event.message),
|
|
1290
|
+
type: "error"
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
tally.finishReason = "error";
|
|
1295
|
+
out.enqueue({
|
|
1296
|
+
error,
|
|
1297
|
+
type: "error"
|
|
1298
|
+
});
|
|
1299
|
+
} finally {
|
|
1300
|
+
if (reasoningOpen) out.enqueue({
|
|
1301
|
+
id: REASONING_ID,
|
|
1302
|
+
type: "reasoning-end"
|
|
1303
|
+
});
|
|
1304
|
+
closeText();
|
|
1305
|
+
const [{ usage }] = await Promise.all([settleCredits(cookie, turn.credits), deleteConversation(cookie, turn.conversationId)]);
|
|
1306
|
+
out.enqueue({
|
|
1307
|
+
finishReason: toFinishReason(finishReasonOf(tally)),
|
|
1308
|
+
providerMetadata: creditMetadata(usage),
|
|
1309
|
+
type: "finish",
|
|
1310
|
+
usage: estimatedUsage(turn.inputTokens, replyTokens(tally))
|
|
1311
|
+
});
|
|
1312
|
+
out.close();
|
|
1313
|
+
}
|
|
1314
|
+
} });
|
|
1315
|
+
const aipassModel = (cookie, modelId) => ({
|
|
1316
|
+
doGenerate: async (options) => {
|
|
1317
|
+
const turn = await startTurn(cookie, modelId, options);
|
|
1318
|
+
const { reader } = turn;
|
|
1319
|
+
const { tally } = reader;
|
|
1320
|
+
const calls = [];
|
|
1321
|
+
const files = [];
|
|
1322
|
+
let text = "";
|
|
1323
|
+
let reasoning = "";
|
|
1324
|
+
try {
|
|
1325
|
+
for await (const event of reader.events) if (event.kind === "text") text += event.text;
|
|
1326
|
+
else if (event.kind === "call") calls.push(event.call);
|
|
1327
|
+
else if (event.kind === "reasoning") reasoning += event.text;
|
|
1328
|
+
else if (event.kind === "file") {
|
|
1329
|
+
const part = assetPart(event.asset);
|
|
1330
|
+
if ("type" in part) files.push(part);
|
|
1331
|
+
else text += part.text;
|
|
1332
|
+
} else tally.finishReason = "error";
|
|
1333
|
+
} finally {
|
|
1334
|
+
await deleteConversation(cookie, turn.conversationId);
|
|
1335
|
+
}
|
|
1336
|
+
const { usage } = await settleCredits(cookie, turn.credits);
|
|
1337
|
+
const content = [];
|
|
1338
|
+
if (reasoning.length > 0) content.push({
|
|
1339
|
+
text: reasoning,
|
|
1340
|
+
type: "reasoning"
|
|
1341
|
+
});
|
|
1342
|
+
if (text.length > 0) content.push({
|
|
1343
|
+
text,
|
|
1344
|
+
type: "text"
|
|
1345
|
+
});
|
|
1346
|
+
content.push(...files, ...calls.map(toolCallPart));
|
|
1347
|
+
return {
|
|
1348
|
+
content,
|
|
1349
|
+
finishReason: toFinishReason(finishReasonOf(tally)),
|
|
1350
|
+
providerMetadata: creditMetadata(usage),
|
|
1351
|
+
usage: estimatedUsage(turn.inputTokens, replyTokens(tally)),
|
|
1352
|
+
warnings: turn.warnings
|
|
1353
|
+
};
|
|
1354
|
+
},
|
|
1355
|
+
doStream: async (options) => {
|
|
1356
|
+
const turn = await startTurn(cookie, modelId, options);
|
|
1357
|
+
return { stream: streamTurn(cookie, turn) };
|
|
1358
|
+
},
|
|
1359
|
+
modelId,
|
|
1360
|
+
provider: PROVIDER,
|
|
1361
|
+
specificationVersion: "v2",
|
|
1362
|
+
supportedUrls: {}
|
|
1363
|
+
});
|
|
1364
|
+
//#endregion
|
|
1365
|
+
//#region src/index.ts
|
|
1366
|
+
const createAipass = (settings) => {
|
|
1367
|
+
if (settings.origin !== void 0) configure({ origin: settings.origin });
|
|
1368
|
+
if (settings.logger !== void 0) configure({ logger: settings.logger });
|
|
1369
|
+
const languageModel = (modelId = DEFAULT_MODEL) => {
|
|
1370
|
+
if (modelId.length === 0 || kindOf(modelId) !== "chat") throw new NoSuchModelError({
|
|
1371
|
+
modelId,
|
|
1372
|
+
modelType: "languageModel"
|
|
1373
|
+
});
|
|
1374
|
+
return aipassModel(settings.cookie, modelId);
|
|
1375
|
+
};
|
|
1376
|
+
return Object.assign(languageModel, { languageModel });
|
|
1377
|
+
};
|
|
1378
|
+
//#endregion
|
|
1379
|
+
export { createAipass };
|