@byokit/accounts 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/dist/accounts.d.ts +10 -1
- package/dist/accounts.js +36 -1
- package/dist/engine.js +2 -2
- package/dist/limits.js +1 -3
- package/dist/portable.d.ts +1 -0
- package/dist/portable.js +1 -0
- package/dist/responses.d.ts +39 -0
- package/dist/responses.js +114 -0
- package/dist/testing/index.d.ts +2 -1
- package/dist/testing/index.js +3 -3
- package/dist/testing/mock-openai.d.ts +5 -0
- package/dist/testing/mock-openai.js +24 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,13 +25,22 @@ Node), into the phone's secure storage or the browser's IndexedDB:
|
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
27
|
import * as SecureStore from 'expo-secure-store';
|
|
28
|
+
import { fetch as streamingFetch } from 'expo/fetch'; // optional: React Native's fetch returns the answer at once
|
|
28
29
|
import { Accounts, secureStore } from '@byokit/accounts';
|
|
29
30
|
|
|
30
|
-
const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byokit.${member}`) });
|
|
31
|
+
const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byokit.${member}`), fetch: streamingFetch as unknown as typeof fetch });
|
|
31
32
|
const shown = await accounts.login(1, 'chatgpt'); // { state: 'waiting', via: 'code', code, url }: open url, show code
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
Then ask ChatGPT with that sign-in, showing streamed pieces while it runs and the returned final answer when it finishes
|
|
36
|
+
(the completion can correct earlier pieces):
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const answer = await accounts.respond(1, { instructions: 'Answer briefly.', input: 'Plan my day', onText: (d) => show(d) });
|
|
40
|
+
show(answer);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Android emulator sign-in, asking, pairing) and
|
|
35
44
|
[`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
36
45
|
|
|
37
46
|
## Which sign-in works where
|
|
@@ -68,11 +77,19 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
|
|
|
68
77
|
Never a shared fallback. Browser storage is readable by scripts on your page: avoid untrusted scripts. Using another
|
|
69
78
|
engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)` with an engine whose
|
|
70
79
|
`credentialStore` is made with `boundStore(member, engineStore)` and whose `readCredential(id)` reads that store.
|
|
80
|
+
- **Asking**: `respond(member, { instructions, input, model?, onText?, signal? })` asks ChatGPT's own answers endpoint
|
|
81
|
+
with the member's sign-in, refreshed first when due, and returns the whole text (`onText` gets each piece as it
|
|
82
|
+
streams; the returned completion is authoritative). A limit or a lapsed sign-in is acted on as `failed()` does, then
|
|
83
|
+
thrown as a `ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
|
|
84
|
+
A web page can't call this endpoint itself (it answers no other web page): ask from the app's own server or over
|
|
85
|
+
`@byokit/link`.
|
|
71
86
|
- **Limits**: `failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that
|
|
72
87
|
doesn't include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable
|
|
73
88
|
account; `keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
|
|
74
89
|
- **Isolation**: ambient discovery is off (no environment variable or credential file is ever consulted), and
|
|
75
|
-
`@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests.
|
|
90
|
+
`@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests. `decoy(root)`
|
|
91
|
+
writes only under the caller-supplied root; the caller owns its creation and cleanup.
|
|
76
92
|
- **A stand-in OpenAI**: `mockOpenAI()` from `@byokit/accounts/testing` (or `node .../testing/mock-openai.ts [port]`)
|
|
77
|
-
answers device code, its page where a person types the code, token exchange, refresh
|
|
78
|
-
sign in end to end with no account. Point the kit at it with
|
|
93
|
+
answers device code, its page where a person types the code, token exchange, refresh, revoke and streamed answers
|
|
94
|
+
(echoing the question), so tests and demos sign in and ask end to end with no account. Point the kit at it with
|
|
95
|
+
`new Accounts({ authBase, apiBase })`.
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CredentialStore, Models } from '@earendil-works/pi-ai';
|
|
2
2
|
import { type Provider } from './catalogue.ts';
|
|
3
3
|
import { type Kind } from './limits.ts';
|
|
4
|
+
import { ResponseError, type Ask } from './responses.ts';
|
|
4
5
|
import { type EndingStore } from './stores.ts';
|
|
5
6
|
import { type Why } from './words.ts';
|
|
6
7
|
/** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
|
|
@@ -62,6 +63,10 @@ export type AccountsOptions<M extends Member = Member> = {
|
|
|
62
63
|
* Phones and browsers sign in and sign out there; on a computer Pi's engine always calls OpenAI, and only sign-out's
|
|
63
64
|
* revoke goes here. */
|
|
64
65
|
authBase?: string;
|
|
66
|
+
/** Where ChatGPT answers `respond`, for a stand-in in tests and demos. */
|
|
67
|
+
apiBase?: string;
|
|
68
|
+
/** The fetch `respond` asks with: one that streams on a phone (Expo's `expo/fetch`). Default: the platform's. */
|
|
69
|
+
fetch?: typeof fetch;
|
|
65
70
|
};
|
|
66
71
|
/** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
|
|
67
72
|
export declare function planOf(access: string): {
|
|
@@ -123,7 +128,11 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
|
|
|
123
128
|
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
124
129
|
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
|
125
130
|
* or null for an error that is not about the account; `network` changes nothing. */
|
|
126
|
-
|
|
131
|
+
/** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
|
|
132
|
+
* failure about the account (a limit, a lapsed sign-in) is acted on as `failed()` does, then thrown as a
|
|
133
|
+
* ResponseError with the words to show. */
|
|
134
|
+
respond(member: M, ask: Ask): Promise<string>;
|
|
135
|
+
failed(member: M, key: string, error: string | ResponseError): Promise<{
|
|
127
136
|
kind: Kind;
|
|
128
137
|
until: number;
|
|
129
138
|
} | null>;
|
package/dist/accounts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { offered, provider } from "./catalogue.js";
|
|
2
2
|
import { claims, PORTABLE, portableEngine } from "./engine.js";
|
|
3
3
|
import { classify, REST_MS } from "./limits.js";
|
|
4
|
+
import { respond, ResponseError } from "./responses.js";
|
|
4
5
|
import { memoryStore } from "./stores.js";
|
|
5
6
|
import { callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
6
7
|
/** Phones and browsers: ChatGPT by device code, no listener. */
|
|
@@ -219,8 +220,42 @@ export class Accounts {
|
|
|
219
220
|
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
220
221
|
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
|
221
222
|
* or null for an error that is not about the account; `network` changes nothing. */
|
|
223
|
+
/** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
|
|
224
|
+
* failure about the account (a limit, a lapsed sign-in) is acted on as `failed()` does, then thrown as a
|
|
225
|
+
* ResponseError with the words to show. */
|
|
226
|
+
async respond(member, ask) {
|
|
227
|
+
const key = 'chatgpt';
|
|
228
|
+
const p = this.offer(key);
|
|
229
|
+
const rt = await this.runtime(member);
|
|
230
|
+
let access;
|
|
231
|
+
try {
|
|
232
|
+
access = (await rt.getAuth(p.pi))?.auth?.apiKey;
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
if ([400, 401, 403].includes(e?.status)) {
|
|
236
|
+
await rt.credentialStore.delete(p.pi);
|
|
237
|
+
this.forget(member, key);
|
|
238
|
+
throw new ResponseError(say('status.needsAgain', { name: p.name }), 'signed_out');
|
|
239
|
+
}
|
|
240
|
+
throw new ResponseError('ChatGPT could not refresh its sign-in. Try again when the network is back.', 'network');
|
|
241
|
+
}
|
|
242
|
+
const c = await rt.readCredential(p.pi).catch(() => undefined);
|
|
243
|
+
if (!access || c?.type !== 'oauth')
|
|
244
|
+
throw new ResponseError(say('status.signedOut', { name: p.name }), 'signed_out');
|
|
245
|
+
try {
|
|
246
|
+
return await respond({ ...ask, access, accountId: String(c.accountId ?? ''), model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch });
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
if (e instanceof ResponseError && e.kind && e.kind !== 'network') {
|
|
250
|
+
const acted = await this.failed(member, key, e);
|
|
251
|
+
if (acted && acted.kind !== e.kind)
|
|
252
|
+
throw new ResponseError(e.message, acted.kind, acted.until);
|
|
253
|
+
}
|
|
254
|
+
throw e;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
222
257
|
async failed(member, key, error) {
|
|
223
|
-
const c = classify(error);
|
|
258
|
+
const c = error instanceof ResponseError ? error.kind && { kind: error.kind, until: error.until } : classify(error);
|
|
224
259
|
if (!c || c.kind === 'network')
|
|
225
260
|
return c;
|
|
226
261
|
if (c.kind === 'signed_out' && await this.recheck(member, key))
|
package/dist/engine.js
CHANGED
|
@@ -82,7 +82,7 @@ export function portableEngine(credentials, { base = 'https://auth.openai.com' }
|
|
|
82
82
|
};
|
|
83
83
|
const tokens = async (what, r) => {
|
|
84
84
|
if (r.status < 200 || r.status > 299)
|
|
85
|
-
throw new Error(`OpenAI Codex token ${what} failed (${r.status})`);
|
|
85
|
+
throw Object.assign(new Error(`OpenAI Codex token ${what} failed (${r.status})`), { status: r.status });
|
|
86
86
|
return credentialOf(json(r.body));
|
|
87
87
|
};
|
|
88
88
|
const refresh = async (c) => {
|
|
@@ -92,7 +92,7 @@ export function portableEngine(credentials, { base = 'https://auth.openai.com' }
|
|
|
92
92
|
return await tokens('refresh', await post('/oauth/token', { grant_type: 'refresh_token', refresh_token: c.refresh, client_id: CLIENT_ID }, true, stop.signal));
|
|
93
93
|
}
|
|
94
94
|
catch (e) {
|
|
95
|
-
throw new Error(`OAuth refresh failed for openai-codex: ${e?.message ?? e}`);
|
|
95
|
+
throw Object.assign(new Error(`OAuth refresh failed for openai-codex: ${e?.message ?? e}`), { status: e?.status });
|
|
96
96
|
}
|
|
97
97
|
finally {
|
|
98
98
|
clearTimeout(t);
|
package/dist/limits.js
CHANGED
|
@@ -4,9 +4,7 @@ export const REST_MS = { rate_limit: 60 * 60_000, overloaded: 5 * 60_000, signed
|
|
|
4
4
|
export function classify(error) {
|
|
5
5
|
const m = /try again in ~?(\d+)\s*(min|h)/i.exec(error);
|
|
6
6
|
const until = m ? Date.now() + Number(m[1]) * (m[2].toLowerCase() === 'h' ? 3_600_000 : 60_000) : 0;
|
|
7
|
-
|
|
8
|
-
// ponytail: told apart by the missing "try again"; a real limit always says when it resets.
|
|
9
|
-
if (/usage limit/i.test(error) && !m)
|
|
7
|
+
if (/your plan doesn't include/i.test(error))
|
|
10
8
|
return { kind: 'not_included', until };
|
|
11
9
|
if (/usage limit|rate.?limit|quota|too many requests|\b429\b/i.test(error))
|
|
12
10
|
return { kind: 'rate_limit', until };
|
package/dist/portable.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type L
|
|
|
2
2
|
export { PROVIDERS, offered, provider, type Provider, type Terms } from './catalogue.ts';
|
|
3
3
|
export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine, type EngineOptions, type Poll } from './engine.ts';
|
|
4
4
|
export { REST_MS, classify, type Kind } from './limits.ts';
|
|
5
|
+
export { ResponseError, limitResponse, respond, sseReader, type Ask } from './responses.ts';
|
|
5
6
|
export { browserStore, memoryStore, recordStore, secureStore, type SecureStoreLike } from './stores.ts';
|
|
6
7
|
export { WORDS, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
|
package/dist/portable.js
CHANGED
|
@@ -4,5 +4,6 @@ export { Accounts, planOf, portable } from "./accounts.js";
|
|
|
4
4
|
export { PROVIDERS, offered, provider } from "./catalogue.js";
|
|
5
5
|
export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine } from "./engine.js";
|
|
6
6
|
export { REST_MS, classify } from "./limits.js";
|
|
7
|
+
export { ResponseError, limitResponse, respond, sseReader } from "./responses.js";
|
|
7
8
|
export { browserStore, memoryStore, recordStore, secureStore } from "./stores.js";
|
|
8
9
|
export { WORDS, callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Kind } from './limits.ts';
|
|
2
|
+
/** A failed answer: the words to show, and the kind an app acts on (null for one that isn't about the account). */
|
|
3
|
+
export declare class ResponseError extends Error {
|
|
4
|
+
kind: Kind | null;
|
|
5
|
+
/** When the provider said to come back (epoch ms), or 0. */
|
|
6
|
+
until: number;
|
|
7
|
+
constructor(message: string, kind: Kind | null, until?: number);
|
|
8
|
+
}
|
|
9
|
+
/** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
|
|
10
|
+
export declare function limitResponse(status: number, body: string, now?: number): {
|
|
11
|
+
kind: Kind | null;
|
|
12
|
+
until: number | null;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
/** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text.
|
|
16
|
+
* An error event throws a ResponseError. */
|
|
17
|
+
export declare function sseReader(onText?: (delta: string) => void): {
|
|
18
|
+
push(chunk: string): void;
|
|
19
|
+
end(): string;
|
|
20
|
+
};
|
|
21
|
+
export type Ask = {
|
|
22
|
+
/** What the model is told to be. */
|
|
23
|
+
instructions: string;
|
|
24
|
+
/** The person's words. */
|
|
25
|
+
input: string;
|
|
26
|
+
/** Default: the provider's strong model in the catalogue. */
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Each piece of the answer as it streams. */
|
|
29
|
+
onText?: (delta: string) => void;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
};
|
|
32
|
+
/** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works. */
|
|
33
|
+
export declare function respond(o: Ask & {
|
|
34
|
+
access: string;
|
|
35
|
+
accountId: string;
|
|
36
|
+
model: string;
|
|
37
|
+
base?: string;
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// One question to ChatGPT, answered as it streams, with fetch alone: what a phone or browser app asks the model with
|
|
2
|
+
// the sign-in it holds. Rules are the shared fixtures (sse.json, limit-responses.json). A fetch that can't stream (React
|
|
3
|
+
// Native's own) still works: the whole answer arrives at once. Expo's `fetch` from 'expo/fetch' streams.
|
|
4
|
+
import { classify } from "./limits.js";
|
|
5
|
+
const limitKind = (code) => code === 'usage_not_included' ? 'not_included'
|
|
6
|
+
: /^(usage_limit_reached|rate_limit_exceeded)$/.test(code) ? 'rate_limit' : null;
|
|
7
|
+
/** A failed answer: the words to show, and the kind an app acts on (null for one that isn't about the account). */
|
|
8
|
+
export class ResponseError extends Error {
|
|
9
|
+
kind;
|
|
10
|
+
/** When the provider said to come back (epoch ms), or 0. */
|
|
11
|
+
until;
|
|
12
|
+
constructor(message, kind, until = 0) { super(message); this.kind = kind; this.until = until; }
|
|
13
|
+
}
|
|
14
|
+
/** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
|
|
15
|
+
export function limitResponse(status, body, now = Date.now()) {
|
|
16
|
+
let err = {};
|
|
17
|
+
try {
|
|
18
|
+
err = JSON.parse(body)?.error ?? {};
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
const code = String(err.code || err.type || '');
|
|
22
|
+
if (limitKind(code) || status === 429) {
|
|
23
|
+
const resets = typeof err.resets_at === 'number' ? err.resets_at * 1000 : null;
|
|
24
|
+
const message = 'You have hit your ChatGPT usage limit' + (err.plan_type ? ` (${String(err.plan_type).toLowerCase()} plan)` : '') + '.' +
|
|
25
|
+
(resets ? ` Try again in ~${Math.max(0, Math.round((resets - now) / 60_000))} min.` : '');
|
|
26
|
+
return { kind: limitKind(code) ?? 'rate_limit', until: resets, message };
|
|
27
|
+
}
|
|
28
|
+
const kind = status === 401 || status === 403 ? 'signed_out' : [500, 502, 503, 504].includes(status) ? 'overloaded' : null;
|
|
29
|
+
return { kind, until: null, message: (typeof err.message === 'string' && err.message) || body || 'Request failed' };
|
|
30
|
+
}
|
|
31
|
+
/** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text.
|
|
32
|
+
* An error event throws a ResponseError. */
|
|
33
|
+
export function sseReader(onText) {
|
|
34
|
+
let buffer = '', text = '', completed;
|
|
35
|
+
let done = false;
|
|
36
|
+
const event = (block) => {
|
|
37
|
+
const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
|
|
38
|
+
if (!data || data === '[DONE]')
|
|
39
|
+
return;
|
|
40
|
+
let e;
|
|
41
|
+
try {
|
|
42
|
+
e = JSON.parse(data);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (e.type === 'response.output_text.delta' && typeof e.delta === 'string') {
|
|
48
|
+
text += e.delta;
|
|
49
|
+
onText?.(e.delta);
|
|
50
|
+
}
|
|
51
|
+
if (e.type === 'response.completed') {
|
|
52
|
+
done = true;
|
|
53
|
+
if (Array.isArray(e.response?.output))
|
|
54
|
+
completed = e.response.output.flatMap((o) => o?.content ?? []).filter((c) => c?.type === 'output_text').map((c) => c.text ?? '').join('');
|
|
55
|
+
}
|
|
56
|
+
const failed = e.type === 'error' ? e : e.type === 'response.failed' ? e.response?.error : undefined;
|
|
57
|
+
if (failed) {
|
|
58
|
+
const message = String(failed.message ?? 'Request failed');
|
|
59
|
+
const c = classify(message);
|
|
60
|
+
throw new ResponseError(message, limitKind(String(failed.code ?? failed.type ?? '')) ?? c?.kind ?? null, c?.until ?? 0);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const drain = (final) => {
|
|
64
|
+
const blocks = buffer.replace(/\r\n/g, '\n').split('\n\n');
|
|
65
|
+
buffer = final ? '' : blocks.pop();
|
|
66
|
+
for (const b of blocks)
|
|
67
|
+
event(b);
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
push(chunk) { buffer += chunk; drain(false); },
|
|
71
|
+
end() {
|
|
72
|
+
drain(true);
|
|
73
|
+
if (!done)
|
|
74
|
+
throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
|
|
75
|
+
if (completed !== undefined && completed !== text) {
|
|
76
|
+
if (completed.startsWith(text))
|
|
77
|
+
onText?.(completed.slice(text.length));
|
|
78
|
+
return completed;
|
|
79
|
+
}
|
|
80
|
+
return text;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works. */
|
|
85
|
+
export async function respond(o) {
|
|
86
|
+
const res = await (o.fetch ?? fetch)(`${o.base ?? 'https://chatgpt.com/backend-api'}/codex/responses`, {
|
|
87
|
+
method: 'POST', signal: o.signal,
|
|
88
|
+
headers: {
|
|
89
|
+
'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${o.access}`,
|
|
90
|
+
'chatgpt-account-id': o.accountId, 'OpenAI-Beta': 'responses=experimental', originator: 'byokit',
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
model: o.model, store: false, stream: true, instructions: o.instructions,
|
|
94
|
+
input: [{ role: 'user', content: [{ type: 'input_text', text: o.input }] }],
|
|
95
|
+
text: { verbosity: 'low' }, reasoning: { effort: 'none' },
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
const e = limitResponse(res.status, await res.text().catch(() => ''));
|
|
100
|
+
throw new ResponseError(e.message, e.kind, e.until ?? 0);
|
|
101
|
+
}
|
|
102
|
+
const reader = sseReader(o.onText);
|
|
103
|
+
const body = res.body;
|
|
104
|
+
if (body?.getReader && typeof TextDecoder !== 'undefined') {
|
|
105
|
+
const r = body.getReader();
|
|
106
|
+
const decoder = new TextDecoder();
|
|
107
|
+
for (let c = await r.read(); !c.done; c = await r.read())
|
|
108
|
+
reader.push(typeof c.value === 'string' ? c.value : decoder.decode(c.value, { stream: true }));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
reader.push(await res.text()); // a fetch that can't stream: the whole answer at once
|
|
112
|
+
}
|
|
113
|
+
return reader.end();
|
|
114
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export declare const CANARY = "canary-byokit-7f3a9c";
|
|
2
2
|
/** Preload this with `node --import` to record touches under `TRACE_ROOTS` in `TRACE_LOG` (both set in `decoy().env`). */
|
|
3
3
|
export declare const traceFs: string;
|
|
4
|
-
|
|
4
|
+
/** Build decoy files under a caller-owned directory; this function does not create or remove its root. */
|
|
5
|
+
export declare function decoy(root: string): {
|
|
5
6
|
root: string;
|
|
6
7
|
home: string;
|
|
7
8
|
roots: string[];
|
package/dist/testing/index.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// shell inside one of them hands down, and an fs tracer. A run must leave the decoys byte for byte as they were, never
|
|
4
4
|
// touch them at all, and never carry a canary into the app's own files.
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { existsSync, mkdirSync,
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
7
|
import { dirname, join } from 'node:path';
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
10
9
|
export const CANARY = 'canary-byokit-7f3a9c';
|
|
@@ -14,7 +13,8 @@ const put = (p, text) => { mkdirSync(dirname(p), { recursive: true }); writeFile
|
|
|
14
13
|
const files = (dir) => existsSync(dir)
|
|
15
14
|
? readdirSync(dir, { recursive: true, withFileTypes: true }).filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name)) : [];
|
|
16
15
|
const hashes = (dirs) => Object.fromEntries(dirs.flatMap(files).map((p) => [p, createHash('sha256').update(readFileSync(p)).digest('hex') + statSync(p).mtimeMs]));
|
|
17
|
-
|
|
16
|
+
/** Build decoy files under a caller-owned directory; this function does not create or remove its root. */
|
|
17
|
+
export function decoy(root) {
|
|
18
18
|
const home = join(root, 'home');
|
|
19
19
|
const marks = join(root, 'marks');
|
|
20
20
|
const trace = join(root, 'trace.log');
|
|
@@ -23,6 +23,11 @@ export declare function mockOpenAI({ port, host, plan, email, expiresIn, log }?:
|
|
|
23
23
|
expiresIn: number;
|
|
24
24
|
/** Drop this many device-code polls on the floor, as a phone does to a backgrounded app. */
|
|
25
25
|
dropPolls: number;
|
|
26
|
+
/** Answer the next question with this HTTP error instead (a limit, a lapsed sign-in), then answer normally. */
|
|
27
|
+
fail: {
|
|
28
|
+
status: number;
|
|
29
|
+
body: string;
|
|
30
|
+
} | undefined;
|
|
26
31
|
};
|
|
27
32
|
approve: (userCode: string, deny?: boolean) => boolean;
|
|
28
33
|
/** The code most recently handed out. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// OpenAI's sign-in, stood in for: device code, its page where a person types the code, token exchange and refresh
|
|
2
|
-
// (rotating), revoke,
|
|
2
|
+
// (rotating), revoke, ChatGPT's streamed answers (`/codex/responses`, echoing the question), and the same CORS answer the
|
|
3
|
+
// real sign-in endpoints give (the answers endpoint, like the real one, answers no web page), so a web page, a phone app or a test signs in
|
|
3
4
|
// end to end with no account and no real network. Run it alone for a demo or an emulator:
|
|
4
5
|
// node packages/accounts/src/testing/mock-openai.ts [port] (21455 by default, never ChatGPT's own 1455)
|
|
5
6
|
import { createServer } from 'node:http';
|
|
@@ -23,11 +24,15 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
|
|
|
23
24
|
expiresIn,
|
|
24
25
|
/** Drop this many device-code polls on the floor, as a phone does to a backgrounded app. */
|
|
25
26
|
dropPolls: 0,
|
|
27
|
+
/** Answer the next question with this HTTP error instead (a limit, a lapsed sign-in), then answer normally. */
|
|
28
|
+
fail: undefined,
|
|
26
29
|
};
|
|
30
|
+
const accessOf = new Map(); // refresh token → the access token issued with it
|
|
27
31
|
const issue = () => {
|
|
28
32
|
const refresh = `rt_${++issued}`;
|
|
29
33
|
state.live.add(refresh);
|
|
30
|
-
|
|
34
|
+
accessOf.set(refresh, mockJwt(plan, email, issued));
|
|
35
|
+
return { access_token: accessOf.get(refresh), refresh_token: refresh, expires_in: state.expiresIn, id_token: 'x' };
|
|
31
36
|
};
|
|
32
37
|
const approve = (userCode, deny = false) => {
|
|
33
38
|
const c = codes.get(userCode.trim().toUpperCase());
|
|
@@ -86,6 +91,23 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
|
|
|
86
91
|
if (state.refuse || !state.live.delete(form.get('refresh_token') ?? ''))
|
|
87
92
|
return send(401, { error: { code: 'refresh_token_reused', message: 'invalid_grant' } });
|
|
88
93
|
return send(200, issue());
|
|
94
|
+
case '/codex/responses': {
|
|
95
|
+
const bearer = req.headers.authorization?.replace(/^Bearer /, '');
|
|
96
|
+
if (state.fail) {
|
|
97
|
+
const f = state.fail;
|
|
98
|
+
state.fail = undefined;
|
|
99
|
+
return send(f.status, f.body);
|
|
100
|
+
}
|
|
101
|
+
if (![...state.live].some((r) => accessOf.get(r) === bearer) || req.headers['chatgpt-account-id'] !== 'acct-1')
|
|
102
|
+
return send(401, { error: { message: 'Provided authentication token is expired. Please try signing in again.' } });
|
|
103
|
+
const text = `You said: ${json().input?.[0]?.content?.[0]?.text ?? ''}`;
|
|
104
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
105
|
+
for (const delta of text.match(/[\s\S]{1,4}/g) ?? []) {
|
|
106
|
+
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta })}\n\n`);
|
|
107
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
108
|
+
}
|
|
109
|
+
return res.end('event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\ndata: [DONE]\n\n');
|
|
110
|
+
}
|
|
89
111
|
case '/oauth/revoke':
|
|
90
112
|
state.live.delete(json().token);
|
|
91
113
|
return send(200, {});
|