thaipass 0.1.4 → 0.1.6
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/dist/index.d.ts +4 -1
- package/dist/index.js +183 -6
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
/**
|
|
@@ -86,6 +86,185 @@ const configure = (changes) => {
|
|
|
86
86
|
Object.assign(current, changes);
|
|
87
87
|
};
|
|
88
88
|
//#endregion
|
|
89
|
+
//#region ../core/src/auth/seal.ts
|
|
90
|
+
/**
|
|
91
|
+
* Login with thaipass, without a database.
|
|
92
|
+
*
|
|
93
|
+
* An app that wants to talk to this gateway has to prove it may drive someone's
|
|
94
|
+
* AI Pass account. The obvious way is to hand it the session cookie, which is
|
|
95
|
+
* what every client does today: the key in a Codex config or a Claude Code
|
|
96
|
+
* environment variable is the whole account, it cannot be revoked, and it has
|
|
97
|
+
* to be copied again by hand each time AI Pass rotates it.
|
|
98
|
+
*
|
|
99
|
+
* A thaipass token is that cookie sealed under a key only the gateway holds.
|
|
100
|
+
* The app carries the sealed bytes and can read nothing in them; the gateway
|
|
101
|
+
* unseals per request and forwards the cookie upstream as before. Nothing is
|
|
102
|
+
* stored anywhere, so a deployment stays as stateless as it is now, and the
|
|
103
|
+
* promise the README makes — that the gateway keeps no credential — still
|
|
104
|
+
* holds: what it keeps is the key that opens one, not the credential.
|
|
105
|
+
*
|
|
106
|
+
* The same envelope carries the authorization code in the middle of a login,
|
|
107
|
+
* with the PKCE challenge and the app's redirect inside it, so a code cannot be
|
|
108
|
+
* replayed as an access token or spent by another app.
|
|
109
|
+
*/
|
|
110
|
+
const ALGORITHM = "aes-256-gcm";
|
|
111
|
+
const IV_BYTES = 12;
|
|
112
|
+
const TAG_BYTES = 16;
|
|
113
|
+
const KEY_BYTES = 32;
|
|
114
|
+
const TOKEN_PREFIX = "tp_v1_";
|
|
115
|
+
const CODE_PREFIX = "tp_c1_";
|
|
116
|
+
/** The wire shape, with short names because the envelope travels in a header. */
|
|
117
|
+
const payloadSchema = z.object({
|
|
118
|
+
a: z.string().optional(),
|
|
119
|
+
c: z.string(),
|
|
120
|
+
h: z.string().optional(),
|
|
121
|
+
r: z.string().optional(),
|
|
122
|
+
s: z.string().optional(),
|
|
123
|
+
sc: z.string().optional(),
|
|
124
|
+
x: z.number()
|
|
125
|
+
});
|
|
126
|
+
const SECOND_MS = 1e3;
|
|
127
|
+
/** Bytes that authenticated under our own key are still parsed, not trusted. */
|
|
128
|
+
const openPayload = (plain) => {
|
|
129
|
+
try {
|
|
130
|
+
return payloadSchema.parse(JSON.parse(plain));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const NO_KEY$1 = "this deployment issues no thaipass tokens: set THAIPASS_TOKEN_KEY to a 32 byte base64 key";
|
|
136
|
+
const MALFORMED = "the token is not a thaipass token";
|
|
137
|
+
const TAMPERED = "the token does not open: it is for another deployment, or it was altered";
|
|
138
|
+
const EXPIRED = "the token has expired, sign in again";
|
|
139
|
+
const prefixFor = (purpose) => purpose === "code" ? CODE_PREFIX : TOKEN_PREFIX;
|
|
140
|
+
/**
|
|
141
|
+
* The key as bytes, or null when this deployment was never given one. A
|
|
142
|
+
* missing key is not an error: a personal gateway that only ever sees raw
|
|
143
|
+
* cookies needs none, and every path here falls back to that.
|
|
144
|
+
*/
|
|
145
|
+
const tokenKey = () => {
|
|
146
|
+
const raw = process.env.THAIPASS_TOKEN_KEY?.trim();
|
|
147
|
+
if (!raw) return null;
|
|
148
|
+
const key = Buffer.from(raw, "base64");
|
|
149
|
+
return key.length === KEY_BYTES ? key : null;
|
|
150
|
+
};
|
|
151
|
+
const issuesTokens = () => tokenKey() !== null;
|
|
152
|
+
const isSealed = (value, purpose = "access") => value.startsWith(prefixFor(purpose));
|
|
153
|
+
const unseal = (value, purpose = "access") => {
|
|
154
|
+
const prefix = prefixFor(purpose);
|
|
155
|
+
if (!value.startsWith(prefix)) return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: MALFORMED
|
|
158
|
+
};
|
|
159
|
+
const key = tokenKey();
|
|
160
|
+
if (!key) return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: NO_KEY$1
|
|
163
|
+
};
|
|
164
|
+
const sealed = Buffer.from(value.slice(prefix.length), "base64url");
|
|
165
|
+
if (sealed.length <= 28) return {
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: MALFORMED
|
|
168
|
+
};
|
|
169
|
+
const iv = sealed.subarray(0, IV_BYTES);
|
|
170
|
+
const body = sealed.subarray(IV_BYTES, sealed.length - TAG_BYTES);
|
|
171
|
+
const tag = sealed.subarray(sealed.length - TAG_BYTES);
|
|
172
|
+
let plain;
|
|
173
|
+
try {
|
|
174
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
175
|
+
decipher.setAAD(Buffer.from(purpose, "utf-8"));
|
|
176
|
+
decipher.setAuthTag(tag);
|
|
177
|
+
plain = decipher.update(body, void 0, "utf-8") + decipher.final("utf-8");
|
|
178
|
+
} catch {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: TAMPERED
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const payload = openPayload(plain);
|
|
185
|
+
if (payload === null) return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: TAMPERED
|
|
188
|
+
};
|
|
189
|
+
if (payload.x * SECOND_MS <= Date.now()) return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: EXPIRED
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
claims: {
|
|
195
|
+
clientId: payload.a,
|
|
196
|
+
codeChallenge: payload.h,
|
|
197
|
+
cookie: payload.c,
|
|
198
|
+
expiresAt: payload.x,
|
|
199
|
+
redirectUri: payload.r,
|
|
200
|
+
scope: payload.sc,
|
|
201
|
+
subject: payload.s
|
|
202
|
+
},
|
|
203
|
+
ok: true
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region ../core/src/aipass/session.ts
|
|
208
|
+
const SESSION_TOKEN = "__Secure-ai_passport_auth.session_token";
|
|
209
|
+
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`;
|
|
210
|
+
const withToken = (cookie, grant) => {
|
|
211
|
+
if (!cookie.includes(SESSION_TOKEN)) return {
|
|
212
|
+
ok: false,
|
|
213
|
+
reason: NO_TOKEN
|
|
214
|
+
};
|
|
215
|
+
return grant ? {
|
|
216
|
+
cookie,
|
|
217
|
+
grant,
|
|
218
|
+
ok: true
|
|
219
|
+
} : {
|
|
220
|
+
cookie,
|
|
221
|
+
ok: true
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Two credentials reach the same account. A thaipass token is the cookie
|
|
226
|
+
* sealed under this deployment's key, which is what `thaipass login` hands an
|
|
227
|
+
* app; a raw Cookie header is what a personal setup has always sent. Unsealing
|
|
228
|
+
* happens here, once, so every route accepts either without knowing about it.
|
|
229
|
+
*/
|
|
230
|
+
const resolve = (value) => {
|
|
231
|
+
const credential = value.trim();
|
|
232
|
+
if (!isSealed(credential)) return withToken(credential);
|
|
233
|
+
const opened = unseal(credential);
|
|
234
|
+
if (!opened.ok) return {
|
|
235
|
+
ok: false,
|
|
236
|
+
reason: opened.reason
|
|
237
|
+
};
|
|
238
|
+
const { claims } = opened;
|
|
239
|
+
return withToken(claims.cookie, {
|
|
240
|
+
clientId: claims.clientId,
|
|
241
|
+
expiresAt: claims.expiresAt,
|
|
242
|
+
scope: claims.scope,
|
|
243
|
+
subject: claims.subject
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
/** A credential handed over directly, as the CLI reads it from the environment or a file. */
|
|
247
|
+
const cookieFromValue = (value) => resolve(value);
|
|
248
|
+
const CLIENT_ID_LENGTH = 16;
|
|
249
|
+
const clientIdFromCookie = (cookie) => createHash("sha256").update(cookie).digest("hex").slice(0, CLIENT_ID_LENGTH);
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/credential.ts
|
|
252
|
+
/**
|
|
253
|
+
* What `createAipass` will actually send upstream.
|
|
254
|
+
*
|
|
255
|
+
* `thaipass login` hands people a `tp_…` token now, so one will be pasted in
|
|
256
|
+
* here sooner or later. The provider is not a gateway — it talks to AI Pass
|
|
257
|
+
* directly — so a token is only usable in a process that also holds the key
|
|
258
|
+
* that sealed it. When it does, the token opens; when it does not, saying so
|
|
259
|
+
* beats letting AI Pass refuse the request for reasons of its own.
|
|
260
|
+
*/
|
|
261
|
+
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.";
|
|
262
|
+
const resolveCredential = (value) => {
|
|
263
|
+
const lookup = cookieFromValue(value);
|
|
264
|
+
if (lookup.ok) return lookup.cookie;
|
|
265
|
+
throw new Error(isSealed(value) && !issuesTokens() ? NO_KEY : lookup.reason);
|
|
266
|
+
};
|
|
267
|
+
//#endregion
|
|
89
268
|
//#region ../core/src/lib/cache.ts
|
|
90
269
|
const ttlCache = (ttlMs) => {
|
|
91
270
|
const entries = /* @__PURE__ */ new Map();
|
|
@@ -199,8 +378,7 @@ const fetchCatalog = async (clientId, cookie, signal) => {
|
|
|
199
378
|
* The AI Pass edge answers some requests itself with a 403 before the chat
|
|
200
379
|
* backend runs, scoring what the prompt contains rather than its length. A
|
|
201
380
|
* retry of the same body gets the same verdict, so the proxy reports it as a
|
|
202
|
-
* request error and hands back the edge's own body
|
|
203
|
-
* undocumented.
|
|
381
|
+
* request error and hands back the edge's own body.
|
|
204
382
|
*/
|
|
205
383
|
const EDGE_REFUSAL_STATUS = 403;
|
|
206
384
|
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";
|
|
@@ -542,8 +720,6 @@ const settleCredits = async (cookie, before) => {
|
|
|
542
720
|
usage: toCreditUsage(start, after)
|
|
543
721
|
};
|
|
544
722
|
};
|
|
545
|
-
const CLIENT_ID_LENGTH = 16;
|
|
546
|
-
const clientIdFromCookie = (cookie) => createHash("sha256").update(cookie).digest("hex").slice(0, CLIENT_ID_LENGTH);
|
|
547
723
|
const thinkingLevelSchema = z.enum([
|
|
548
724
|
"low",
|
|
549
725
|
"medium",
|
|
@@ -1474,6 +1650,7 @@ const aipassModel = (cookie, modelId) => ({
|
|
|
1474
1650
|
//#endregion
|
|
1475
1651
|
//#region src/index.ts
|
|
1476
1652
|
const createAipass = (settings) => {
|
|
1653
|
+
const cookie = resolveCredential(settings.cookie);
|
|
1477
1654
|
if (settings.origin !== void 0) configure({ origin: settings.origin });
|
|
1478
1655
|
if (settings.logger !== void 0) configure({ logger: settings.logger });
|
|
1479
1656
|
const languageModel = (modelId = DEFAULT_MODEL) => {
|
|
@@ -1481,7 +1658,7 @@ const createAipass = (settings) => {
|
|
|
1481
1658
|
modelId,
|
|
1482
1659
|
modelType: "languageModel"
|
|
1483
1660
|
});
|
|
1484
|
-
return aipassModel(
|
|
1661
|
+
return aipassModel(cookie, modelId);
|
|
1485
1662
|
};
|
|
1486
1663
|
return Object.assign(languageModel, { languageModel });
|
|
1487
1664
|
};
|