thaipass 0.1.5 → 0.1.7

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
@@ -13,7 +13,7 @@ import { createAipass } from "thaipass";
13
13
  const aipass = createAipass({ cookie: process.env.AIPASS_COOKIE ?? "" });
14
14
 
15
15
  const result = streamText({
16
- model: aipass("claude-sonnet-5@default"),
16
+ model: aipass("claude-sonnet-5@azure"),
17
17
  prompt: "hi",
18
18
  });
19
19
  ```
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ import { z } from "zod";
8
8
  * drift warning. A media id needs per-model knowledge the catalog does not
9
9
  * carry, so the media lists below stay the gate for their routes.
10
10
  */
11
- declare const CHAT_MODELS: readonly ["gpt-5.6-terra", "gpt-5.6-sol", "claude-opus-5@azure", "claude-sonnet-5@default", "gemini-3.1-pro-preview", "gemini-3.7-flash", "gemini-3.1-flash-lite", "glm-5.2", "grok-4.3", "DeepSeek-V3.2", "minimax-m2-maas", "qwen3-next-80b-a3b-instruct-maas", "Kimi-K2.7-Code", "Mistral-Large-3", "Mistral-Medium-3", "Llama-4-Scout-17B-16E-Instruct-1", "Llama-4-Maverick-17B-128E-Instruct-FP8-1", "pathumma-thaillm-8b", "openai-deep-research", "openthai2.0-legal@jts", "sonar", "sonar-reasoning-pro", "sonar-deep-research"];
11
+ declare const CHAT_MODELS: readonly ["gpt-5.6-terra", "gpt-5.6-sol", "claude-opus-5@azure", "claude-sonnet-5@azure", "gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-3.5-flash-lite", "glm-5.3", "grok-4.3", "DeepSeek-V3.2", "minimax-m2-maas", "qwen3-next-80b-a3b-instruct-maas", "Kimi-K2.7-Code", "Mistral-Large-3", "Mistral-Medium-3", "Llama-4-Scout-17B-16E-Instruct-1", "Llama-4-Maverick-17B-128E-Instruct-FP8-1", "pathumma-thaillm-8b", "openai-deep-research", "openthai2.0-legal", "openthai2.0-legal@jts", "sonar", "sonar-reasoning-pro", "sonar-deep-research"];
12
12
  type KnownChatModel = (typeof CHAT_MODELS)[number];
13
13
  //#endregion
14
14
  //#region ../core/src/lib/config.d.ts
@@ -24,7 +24,10 @@ interface UpstreamLogger {
24
24
  /** The known ids complete in an editor; any other string is left to the catalog. */
25
25
  export type AipassModelId = KnownChatModel | (string & Record<never, never>);
26
26
  export interface AipassProviderSettings {
27
- /** The Cookie header of a browser signed in to AI Pass. */
27
+ /**
28
+ * The Cookie header of a browser signed in to AI Pass, or a thaipass token
29
+ * when this process holds the `THAIPASS_TOKEN_KEY` that sealed it.
30
+ */
28
31
  readonly cookie: string;
29
32
  /** Process-wide; defaults to https://de.aipass.net. */
30
33
  readonly origin?: string;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { APICallError, NoSuchModelError } from "@ai-sdk/provider";
2
2
  import { z } from "zod";
3
- import { createHash } from "node:crypto";
3
+ import { createDecipheriv, createHash } from "node:crypto";
4
4
  import { parseJsonEventStream } from "@ai-sdk/provider-utils";
5
5
  //#region ../core/src/aipass/models.ts
6
6
  /**
@@ -14,11 +14,11 @@ const CHAT_MODELS = [
14
14
  "gpt-5.6-terra",
15
15
  "gpt-5.6-sol",
16
16
  "claude-opus-5@azure",
17
- "claude-sonnet-5@default",
17
+ "claude-sonnet-5@azure",
18
18
  "gemini-3.1-pro-preview",
19
- "gemini-3.7-flash",
20
- "gemini-3.1-flash-lite",
21
- "glm-5.2",
19
+ "gemini-3.8-flash",
20
+ "gemini-3.5-flash-lite",
21
+ "glm-5.3",
22
22
  "grok-4.3",
23
23
  "DeepSeek-V3.2",
24
24
  "minimax-m2-maas",
@@ -30,6 +30,7 @@ const CHAT_MODELS = [
30
30
  "Llama-4-Maverick-17B-128E-Instruct-FP8-1",
31
31
  "pathumma-thaillm-8b",
32
32
  "openai-deep-research",
33
+ "openthai2.0-legal",
33
34
  "openthai2.0-legal@jts",
34
35
  "sonar",
35
36
  "sonar-reasoning-pro",
@@ -43,6 +44,7 @@ const CHAT_MODELS = [
43
44
  const IMAGE_MODELS = [
44
45
  "gpt-image-2",
45
46
  "gemini-3-pro-image",
47
+ "gemini-3.1-flash-image",
46
48
  "gemini-2.5-flash-image",
47
49
  "seedream-4.0",
48
50
  "seedream-5.0-lite"
@@ -72,7 +74,7 @@ z.string().min(1, { error: "model must not be empty, see GET /v1/models" }).meta
72
74
  description: "A chat model id from GET /v1/models. Ids are case-sensitive and Claude carries a @provider suffix.",
73
75
  examples: [...CHAT_MODELS]
74
76
  });
75
- const DEFAULT_MODEL = "gemini-3.1-flash-lite";
77
+ const DEFAULT_MODEL = "gemini-3.5-flash-lite";
76
78
  const ANY_MODELS = [...CHAT_MODELS, ...MEDIA_MODELS];
77
79
  z.enum(ANY_MODELS, { error: "unknown model, see GET /v1/models" });
78
80
  const current = {
@@ -86,6 +88,185 @@ const configure = (changes) => {
86
88
  Object.assign(current, changes);
87
89
  };
88
90
  //#endregion
91
+ //#region ../core/src/auth/seal.ts
92
+ /**
93
+ * Login with thaipass, without a database.
94
+ *
95
+ * An app that wants to talk to this gateway has to prove it may drive someone's
96
+ * AI Pass account. The obvious way is to hand it the session cookie, which is
97
+ * what every client does today: the key in a Codex config or a Claude Code
98
+ * environment variable is the whole account, it cannot be revoked, and it has
99
+ * to be copied again by hand each time AI Pass rotates it.
100
+ *
101
+ * A thaipass token is that cookie sealed under a key only the gateway holds.
102
+ * The app carries the sealed bytes and can read nothing in them; the gateway
103
+ * unseals per request and forwards the cookie upstream as before. Nothing is
104
+ * stored anywhere, so a deployment stays as stateless as it is now, and the
105
+ * promise the README makes — that the gateway keeps no credential — still
106
+ * holds: what it keeps is the key that opens one, not the credential.
107
+ *
108
+ * The same envelope carries the authorization code in the middle of a login,
109
+ * with the PKCE challenge and the app's redirect inside it, so a code cannot be
110
+ * replayed as an access token or spent by another app.
111
+ */
112
+ const ALGORITHM = "aes-256-gcm";
113
+ const IV_BYTES = 12;
114
+ const TAG_BYTES = 16;
115
+ const KEY_BYTES = 32;
116
+ const TOKEN_PREFIX = "tp_v1_";
117
+ const CODE_PREFIX = "tp_c1_";
118
+ /** The wire shape, with short names because the envelope travels in a header. */
119
+ const payloadSchema = z.object({
120
+ a: z.string().optional(),
121
+ c: z.string(),
122
+ h: z.string().optional(),
123
+ r: z.string().optional(),
124
+ s: z.string().optional(),
125
+ sc: z.string().optional(),
126
+ x: z.number()
127
+ });
128
+ const SECOND_MS = 1e3;
129
+ /** Bytes that authenticated under our own key are still parsed, not trusted. */
130
+ const openPayload = (plain) => {
131
+ try {
132
+ return payloadSchema.parse(JSON.parse(plain));
133
+ } catch {
134
+ return null;
135
+ }
136
+ };
137
+ const NO_KEY$1 = "this deployment issues no thaipass tokens: set THAIPASS_TOKEN_KEY to a 32 byte base64 key";
138
+ const MALFORMED = "the token is not a thaipass token";
139
+ const TAMPERED = "the token does not open: it is for another deployment, or it was altered";
140
+ const EXPIRED = "the token has expired, sign in again";
141
+ const prefixFor = (purpose) => purpose === "code" ? CODE_PREFIX : TOKEN_PREFIX;
142
+ /**
143
+ * The key as bytes, or null when this deployment was never given one. A
144
+ * missing key is not an error: a personal gateway that only ever sees raw
145
+ * cookies needs none, and every path here falls back to that.
146
+ */
147
+ const tokenKey = () => {
148
+ const raw = process.env.THAIPASS_TOKEN_KEY?.trim();
149
+ if (!raw) return null;
150
+ const key = Buffer.from(raw, "base64");
151
+ return key.length === KEY_BYTES ? key : null;
152
+ };
153
+ const issuesTokens = () => tokenKey() !== null;
154
+ const isSealed = (value, purpose = "access") => value.startsWith(prefixFor(purpose));
155
+ const unseal = (value, purpose = "access") => {
156
+ const prefix = prefixFor(purpose);
157
+ if (!value.startsWith(prefix)) return {
158
+ ok: false,
159
+ reason: MALFORMED
160
+ };
161
+ const key = tokenKey();
162
+ if (!key) return {
163
+ ok: false,
164
+ reason: NO_KEY$1
165
+ };
166
+ const sealed = Buffer.from(value.slice(prefix.length), "base64url");
167
+ if (sealed.length <= 28) return {
168
+ ok: false,
169
+ reason: MALFORMED
170
+ };
171
+ const iv = sealed.subarray(0, IV_BYTES);
172
+ const body = sealed.subarray(IV_BYTES, sealed.length - TAG_BYTES);
173
+ const tag = sealed.subarray(sealed.length - TAG_BYTES);
174
+ let plain;
175
+ try {
176
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
177
+ decipher.setAAD(Buffer.from(purpose, "utf-8"));
178
+ decipher.setAuthTag(tag);
179
+ plain = decipher.update(body, void 0, "utf-8") + decipher.final("utf-8");
180
+ } catch {
181
+ return {
182
+ ok: false,
183
+ reason: TAMPERED
184
+ };
185
+ }
186
+ const payload = openPayload(plain);
187
+ if (payload === null) return {
188
+ ok: false,
189
+ reason: TAMPERED
190
+ };
191
+ if (payload.x * SECOND_MS <= Date.now()) return {
192
+ ok: false,
193
+ reason: EXPIRED
194
+ };
195
+ return {
196
+ claims: {
197
+ clientId: payload.a,
198
+ codeChallenge: payload.h,
199
+ cookie: payload.c,
200
+ expiresAt: payload.x,
201
+ redirectUri: payload.r,
202
+ scope: payload.sc,
203
+ subject: payload.s
204
+ },
205
+ ok: true
206
+ };
207
+ };
208
+ //#endregion
209
+ //#region ../core/src/aipass/session.ts
210
+ const SESSION_TOKEN = "__Secure-ai_passport_auth.session_token";
211
+ const NO_TOKEN = `the credential carries no ${SESSION_TOKEN}, send the whole Cookie header from a logged-in browser session rather than the token on its own`;
212
+ const withToken = (cookie, grant) => {
213
+ if (!cookie.includes(SESSION_TOKEN)) return {
214
+ ok: false,
215
+ reason: NO_TOKEN
216
+ };
217
+ return grant ? {
218
+ cookie,
219
+ grant,
220
+ ok: true
221
+ } : {
222
+ cookie,
223
+ ok: true
224
+ };
225
+ };
226
+ /**
227
+ * Two credentials reach the same account. A thaipass token is the cookie
228
+ * sealed under this deployment's key, which is what `thaipass login` hands an
229
+ * app; a raw Cookie header is what a personal setup has always sent. Unsealing
230
+ * happens here, once, so every route accepts either without knowing about it.
231
+ */
232
+ const resolve = (value) => {
233
+ const credential = value.trim();
234
+ if (!isSealed(credential)) return withToken(credential);
235
+ const opened = unseal(credential);
236
+ if (!opened.ok) return {
237
+ ok: false,
238
+ reason: opened.reason
239
+ };
240
+ const { claims } = opened;
241
+ return withToken(claims.cookie, {
242
+ clientId: claims.clientId,
243
+ expiresAt: claims.expiresAt,
244
+ scope: claims.scope,
245
+ subject: claims.subject
246
+ });
247
+ };
248
+ /** A credential handed over directly, as the CLI reads it from the environment or a file. */
249
+ const cookieFromValue = (value) => resolve(value);
250
+ const CLIENT_ID_LENGTH = 16;
251
+ const clientIdFromCookie = (cookie) => createHash("sha256").update(cookie).digest("hex").slice(0, CLIENT_ID_LENGTH);
252
+ //#endregion
253
+ //#region src/credential.ts
254
+ /**
255
+ * What `createAipass` will actually send upstream.
256
+ *
257
+ * `thaipass login` hands people a `tp_…` token now, so one will be pasted in
258
+ * here sooner or later. The provider is not a gateway — it talks to AI Pass
259
+ * directly — so a token is only usable in a process that also holds the key
260
+ * that sealed it. When it does, the token opens; when it does not, saying so
261
+ * beats letting AI Pass refuse the request for reasons of its own.
262
+ */
263
+ const NO_KEY = "that is a thaipass token, and this provider talks to AI Pass directly. Give it the Cookie header, or set THAIPASS_TOKEN_KEY to the key that sealed the token.";
264
+ const resolveCredential = (value) => {
265
+ const lookup = cookieFromValue(value);
266
+ if (lookup.ok) return lookup.cookie;
267
+ throw new Error(isSealed(value) && !issuesTokens() ? NO_KEY : lookup.reason);
268
+ };
269
+ //#endregion
89
270
  //#region ../core/src/lib/cache.ts
90
271
  const ttlCache = (ttlMs) => {
91
272
  const entries = /* @__PURE__ */ new Map();
@@ -122,7 +303,13 @@ const browserPostHeaders = (cookie, referer, contentType) => ({
122
303
  });
123
304
  /** Headers for a JSON action made from inside one conversation's page. */
124
305
  const conversationJsonHeaders = (cookie, conversationId) => browserPostHeaders(cookie, `${config.origin}/chat/${conversationId}`, "application/json");
125
- const loadJson = async (path, schema, cookie, signal) => {
306
+ /**
307
+ * The same GET as {@link loadJson}, keeping why it failed. A caller that
308
+ * answers an HTTP request needs the reason: "no catalog" reads identically
309
+ * whether the cookie is dead, the network blinked, or upstream renamed a
310
+ * field, and those want three different fixes.
311
+ */
312
+ const loadJsonResult = async (path, schema, cookie, signal) => {
126
313
  let payload;
127
314
  try {
128
315
  const response = await fetch(`${config.origin}${path}`, {
@@ -130,13 +317,29 @@ const loadJson = async (path, schema, cookie, signal) => {
130
317
  redirect: "manual",
131
318
  signal
132
319
  });
133
- if (!response.ok) return null;
320
+ if (!response.ok) return {
321
+ data: null,
322
+ failure: `upstream ${response.status}`
323
+ };
134
324
  payload = await response.json();
135
325
  } catch {
136
- return null;
326
+ return {
327
+ data: null,
328
+ failure: "network"
329
+ };
137
330
  }
138
331
  const decoded = schema.safeParse(payload);
139
- return decoded.success ? decoded.data : null;
332
+ return decoded.success ? {
333
+ data: decoded.data,
334
+ failure: null
335
+ } : {
336
+ data: null,
337
+ failure: "schema"
338
+ };
339
+ };
340
+ /** The body alone, for a loader whose caller has nowhere to put the reason. */
341
+ const loadJson = async (path, schema, cookie, signal) => {
342
+ return (await loadJsonResult(path, schema, cookie, signal)).data;
140
343
  };
141
344
  //#endregion
142
345
  //#region ../core/src/aipass/catalog.ts
@@ -176,22 +379,36 @@ const reportDrift = (catalog) => {
176
379
  });
177
380
  };
178
381
  const load = async (cookie, signal) => {
179
- const listed = await loadJson(CATALOG_PATH, catalogSchema, cookie, signal);
180
- if (!listed) return null;
181
- return new Map(listed.data.map((entry) => [entry.id, {
182
- free: entry.isFreeCredit ?? false,
183
- ready: entry.ready ?? true,
184
- thinking: entry.thinkingConfig?.supportedLevels ?? null
185
- }]));
382
+ const { data: listed, failure } = await loadJsonResult(CATALOG_PATH, catalogSchema, cookie, signal);
383
+ if (!listed) return {
384
+ catalog: null,
385
+ failure
386
+ };
387
+ return {
388
+ catalog: new Map(listed.data.map((entry) => [entry.id, {
389
+ free: entry.isFreeCredit ?? false,
390
+ ready: entry.ready ?? true,
391
+ thinking: entry.thinkingConfig?.supportedLevels ?? null
392
+ }])),
393
+ failure: null
394
+ };
186
395
  };
187
- const fetchCatalog = async (clientId, cookie, signal) => {
396
+ /** The catalog with the reason it is missing, for a caller that reports it. */
397
+ const fetchCatalogResult = async (clientId, cookie, signal) => {
188
398
  const cached = cache.get(clientId);
189
- if (cached) return cached;
190
- const catalog = await load(cookie, signal);
191
- if (!catalog) return null;
192
- cache.set(clientId, catalog);
193
- reportDrift(catalog);
194
- return catalog;
399
+ if (cached) return {
400
+ catalog: cached,
401
+ failure: null
402
+ };
403
+ const loaded = await load(cookie, signal);
404
+ if (!loaded.catalog) return loaded;
405
+ cache.set(clientId, loaded.catalog);
406
+ reportDrift(loaded.catalog);
407
+ return loaded;
408
+ };
409
+ /** The catalog alone, for a caller with nowhere to put the reason. */
410
+ const fetchCatalog = async (clientId, cookie, signal) => {
411
+ return (await fetchCatalogResult(clientId, cookie, signal)).catalog;
195
412
  };
196
413
  //#endregion
197
414
  //#region ../core/src/aipass/refusal.ts
@@ -541,8 +758,6 @@ const settleCredits = async (cookie, before) => {
541
758
  usage: toCreditUsage(start, after)
542
759
  };
543
760
  };
544
- const CLIENT_ID_LENGTH = 16;
545
- const clientIdFromCookie = (cookie) => createHash("sha256").update(cookie).digest("hex").slice(0, CLIENT_ID_LENGTH);
546
761
  const thinkingLevelSchema = z.enum([
547
762
  "low",
548
763
  "medium",
@@ -983,12 +1198,29 @@ const roleLabel = (turn, names) => {
983
1198
  const turnText = (turn) => {
984
1199
  return [turn.content.trim(), ...(turn.calls ?? []).map(renderCall)].filter((part) => part.length > 0).join("\n\n");
985
1200
  };
1201
+ /**
1202
+ * Stands in for a caller who sent no system message, so the tool guide below
1203
+ * it reads as instruction rather than as a description of tools that live
1204
+ * somewhere else.
1205
+ *
1206
+ * Measured 2026-09-19 against ten models, one request at a time with the
1207
+ * conditions interleaved, five rounds each: with a single tool attached and
1208
+ * no system message, `gpt-5.6-sol` and `gpt-5.6-terra` never called it,
1209
+ * `gemini-3.1-pro-preview` and `glm-5.3` managed 2/5, `Kimi-K2.7-Code` 3/5
1210
+ * and `grok-4.3` 4/5. `gpt-5.6-sol` said why: "I can't access the
1211
+ * select_choice tool here". It could see the name, so the guide had arrived.
1212
+ * With one line of system ahead of it, all ten called the tool on every
1213
+ * round. Graders in the autoevals shape send one user message and no system
1214
+ * message, which is how this surfaced.
1215
+ */
1216
+ const IMPLIED_SYSTEM = "You are an assistant with the tools described below. Call them when they apply.";
986
1217
  const flattenPrompt = ({ tools, turns }) => {
987
1218
  const system = turns.filter((turn) => turn.role === "system").map((turn) => turn.content).filter((text) => text.length > 0).join("\n\n");
988
1219
  const convo = turns.filter((turn) => turn.role !== "system");
989
1220
  if (system.length === 0 && tools.length === 0 && convo.length === 1 && convo[0]?.role === "user" && convo[0].calls === void 0) return convo[0].content;
990
1221
  const blocks = [];
991
1222
  if (system.length > 0) blocks.push(system.trim());
1223
+ else if (tools.length > 0) blocks.push(IMPLIED_SYSTEM);
992
1224
  if (tools.length > 0) blocks.push(renderToolGuide(tools));
993
1225
  const names = toolNames(convo);
994
1226
  const lines = [];
@@ -1473,6 +1705,7 @@ const aipassModel = (cookie, modelId) => ({
1473
1705
  //#endregion
1474
1706
  //#region src/index.ts
1475
1707
  const createAipass = (settings) => {
1708
+ const cookie = resolveCredential(settings.cookie);
1476
1709
  if (settings.origin !== void 0) configure({ origin: settings.origin });
1477
1710
  if (settings.logger !== void 0) configure({ logger: settings.logger });
1478
1711
  const languageModel = (modelId = DEFAULT_MODEL) => {
@@ -1480,7 +1713,7 @@ const createAipass = (settings) => {
1480
1713
  modelId,
1481
1714
  modelType: "languageModel"
1482
1715
  });
1483
- return aipassModel(settings.cookie, modelId);
1716
+ return aipassModel(cookie, modelId);
1484
1717
  };
1485
1718
  return Object.assign(languageModel, { languageModel });
1486
1719
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaipass",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AI SDK provider for AI Pass, using your own session cookie.",
5
5
  "keywords": [
6
6
  "ai-sdk",