@byokit/accounts 0.4.1 → 0.5.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/CHANGELOG.md +4 -0
- package/README.md +233 -57
- package/dist/accounts.d.ts +10 -3
- package/dist/accounts.js +2 -8
- package/dist/portable.d.ts +1 -1
- package/dist/portable.js +1 -1
- package/dist/responses.d.ts +160 -6
- package/dist/responses.js +82 -17
- package/dist/testing/mock-openai.js +35 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.0 (2026-09-29)
|
|
6
|
+
|
|
7
|
+
- `respond` passes the whole question through: a message array (many turns, pictures with `input_image`, a `function_call` with its `function_call_output`), `tools` and `tool_choice` (the app's own function tools and built-ins, including `image_generation`), how hard the model thinks (`reasoning.effort`), and how long the answer is with the shape it must follow (`text.verbosity`, `text.format`). With `tools` the result is the text with every output item (`isFunctionCall` spots a call); without, the plain text as before. `onEvent` sees each tool call and output item as it streams.
|
|
8
|
+
|
|
5
9
|
## 0.4.1 (2026-09-29)
|
|
6
10
|
|
|
7
11
|
- FIX: the packed `dist/words.d.ts` keeps `with { type: 'json' }` on its `./words.json` import, so a strict NodeNext consumer with `skipLibCheck: false` no longer fails with TS1543.
|
package/README.md
CHANGED
|
@@ -1,10 +1,75 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">@byokit/accounts</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/@byokit/accounts"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/accounts?style=flat&label=npm" /></a>
|
|
5
|
+
<a href="https://github.com/umeranjum17/byokit/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/byokit/ci.yml?style=flat&branch=main" /></a>
|
|
6
|
+
<a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
|
|
7
|
+
<img alt="Node | Electron | browsers | React Native" src="https://img.shields.io/badge/platform-Node%20%7C%20Electron%20%7C%20browsers%20%7C%20React%20Native-666?style=flat" />
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center"><strong>Sign in with the AI plan you already pay for, inside your own app.</strong><br/>
|
|
11
|
+
ChatGPT on every platform; OpenRouter on computers when an app offers it (API billing, never by default); Grok and
|
|
12
|
+
GitHub Copilot hidden by default. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
|
|
13
|
+
(a PWA, Electron's renderer) and on a phone (React Native and Expo, iOS and Android). One import; your bundler picks
|
|
14
|
+
the platform's side (`package.json`'s `react-native` and `browser` conditions).</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/pwa-1-signed-out.png" width="240" alt="The page "byokit in a browser", signed out: "ChatGPT isn't signed in yet." above a Sign in with ChatGPT button" />
|
|
18
|
+
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/pwa-2-code.png" width="240" alt="The same page signing in: "Signing in to ChatGPT…", "On the ChatGPT page, type this code:" WDJB-MJHT, an Open ChatGPT link and a Cancel button" />
|
|
19
|
+
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/pwa-3-connected.png" width="240" alt="The same page signed in: "ChatGPT is connected.", sara@example.com, plus plan, with Check the sign-in and Sign out buttons" />
|
|
20
|
+
</p>
|
|
21
|
+
<p align="center"><sub><a href="../../examples/pwa"><code>examples/pwa</code></a> signing in by device code in headless Chromium (Playwright), against the kit's stand-in OpenAI (<code>mockOpenAI()</code>), not the real one; the pictured code is in OpenAI's format.</sub></p>
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install @byokit/accounts
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
[](https://www.npmjs.com/package/@byokit/accounts) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=accounts-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm install @byokit/accounts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The whole ChatGPT flow, run against the stand-in OpenAI so it needs no account and no network beyond loopback: sign in
|
|
38
|
+
by device code, see the status, ask.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { Accounts, portable } from '@byokit/accounts';
|
|
42
|
+
import { mockOpenAI } from '@byokit/accounts/testing';
|
|
43
|
+
|
|
44
|
+
const openai = await mockOpenAI(); // a stand-in OpenAI on 127.0.0.1, no account needed
|
|
45
|
+
const accounts = new Accounts(
|
|
46
|
+
{ authBase: openai.base, apiBase: openai.base }, // default store: in memory
|
|
47
|
+
portable, // device code with fetch alone, as on a phone or in a browser
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const shown = await accounts.login(1, 'chatgpt');
|
|
51
|
+
console.log(shown?.state, shown?.via, shown?.code);
|
|
52
|
+
openai.approve(shown!.code!); // the person types the code on the provider's page
|
|
53
|
+
|
|
54
|
+
await accounts.finished(1, 'chatgpt'); // the whole sign-in, once the code is approved
|
|
55
|
+
console.log((await accounts.status(1, 'chatgpt')).words);
|
|
56
|
+
|
|
57
|
+
const answer = await accounts.respond(1, { instructions: 'Answer briefly.', input: 'Plan my day', onText: (d) => process.stdout.write(d) });
|
|
58
|
+
console.log('\nfinal:', answer);
|
|
59
|
+
await openai.close();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
waiting code MOCK-10001
|
|
64
|
+
ChatGPT is connected.
|
|
65
|
+
You said: Plan my day
|
|
66
|
+
final: You said: Plan my day
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The stand-in echoes the question. In an app, drop `authBase`, `apiBase` and `portable`, and give each person a store,
|
|
70
|
+
as below.
|
|
71
|
+
|
|
72
|
+
### On a computer
|
|
8
73
|
|
|
9
74
|
On a computer it uses Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) sign-in
|
|
10
75
|
flows, pinned exactly:
|
|
@@ -20,8 +85,10 @@ const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: '
|
|
|
20
85
|
(await accounts.status(1, 'chatgpt')).words; // "ChatGPT is connected."
|
|
21
86
|
```
|
|
22
87
|
|
|
23
|
-
On a phone or in a browser
|
|
24
|
-
|
|
88
|
+
### On a phone or in a browser
|
|
89
|
+
|
|
90
|
+
The same `Accounts` signs in to ChatGPT by device code with `fetch` alone (Pi's flows need Node), into the phone's
|
|
91
|
+
secure storage or the browser's IndexedDB:
|
|
25
92
|
|
|
26
93
|
```ts
|
|
27
94
|
import * as SecureStore from 'expo-secure-store';
|
|
@@ -32,23 +99,41 @@ const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byo
|
|
|
32
99
|
const shown = await accounts.login(1, 'chatgpt'); // { state: 'waiting', via: 'code', code, url }: open url, show code
|
|
33
100
|
```
|
|
34
101
|
|
|
35
|
-
Then ask ChatGPT with that sign-in, showing streamed pieces while it runs and the returned final answer when it
|
|
36
|
-
(the completion can correct earlier pieces):
|
|
102
|
+
Then ask ChatGPT with that sign-in, showing streamed pieces while it runs and the returned final answer when it
|
|
103
|
+
finishes (the completion can correct earlier pieces):
|
|
37
104
|
|
|
38
105
|
```ts
|
|
39
106
|
const answer = await accounts.respond(1, { instructions: 'Answer briefly.', input: 'Plan my day', onText: (d) => show(d) });
|
|
40
107
|
show(answer);
|
|
41
108
|
```
|
|
42
109
|
|
|
43
|
-
Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Android emulator sign-in, asking, pairing)
|
|
44
|
-
[`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
110
|
+
Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Android emulator sign-in, asking, pairing)
|
|
111
|
+
and [`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
112
|
+
|
|
113
|
+
## API at a glance
|
|
114
|
+
|
|
115
|
+
| Export | What it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `Accounts` | Sign-in, status, sign-out, asking and limits for each member: `login`, `finished`, `status`, `plan`, `logout`, `respond`, `failed`, `ladder`, `keepFresh` |
|
|
118
|
+
| `portable`, `computer`, `loopback` | The platform `Accounts` runs on: device code with `fetch` alone, or (Node entry only) Pi's flows and the loopback listener |
|
|
119
|
+
| `memoryStore`, `fileStore`, `secureStore`, `browserStore`, `recordStore` | One store per person: in memory, a 0600 file (Node entry only), Keychain/Keystore, IndexedDB, or your own load and save |
|
|
120
|
+
| `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing, terms status, reason and source |
|
|
121
|
+
| `billingWords`, `say`, `WORDS`, `signInError`, `failure`, `clock`, `callbackPage` | The plain sentences every app shows the same way (`words.json`), a time in words, and the page a browser sees after a sign-in |
|
|
122
|
+
| `respond`, `ResponseError`, `sseReader`, `limitResponse`, `isFunctionCall` | Ask ChatGPT's answers endpoint with a sign-in, with tools, pictures, thinking effort and an answer shape; the error with the words to show and the kind acted on |
|
|
123
|
+
| `classify`, `REST_MS` | An error's kind (limit, overload, plan without this use, lapsed sign-in, network) and default rest times |
|
|
124
|
+
| `planOf`, `claims` | The ChatGPT plan and email behind a sign-in, from its own token |
|
|
125
|
+
| `deviceStart`, `devicePoll`, `credentialOf`, `portableEngine`, `PORTABLE` | The device-code flow, the sign-in built from a token answer, and the engine under `portable` |
|
|
126
|
+
| `isolate`, `INHERITED`, `emptyAuthContext` (`/isolate`) | Scrub inherited Pi settings and provider keys; ambient discovery off |
|
|
127
|
+
| `mockOpenAI`, `mockJwt`, `decoy`, `traceFs`, `CANARY` (`/testing`) | A stand-in OpenAI, and the decoy-HOME harness and fs tracer for isolation tests |
|
|
128
|
+
|
|
129
|
+
`computer`, `loopback` and `fileStore` come from the Node entry only; `isolate` and `/testing` need Node too.
|
|
45
130
|
|
|
46
131
|
## Which sign-in works where
|
|
47
132
|
|
|
48
133
|
| | Computer (Node, Electron main) | Browser (PWA, Electron renderer) | Phone (React Native: iOS, Android) |
|
|
49
134
|
|---|---|---|---|
|
|
50
|
-
| ChatGPT | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
|
|
51
|
-
| OpenRouter | Its own page, back to this computer (Pi's flow), when an app offers it (
|
|
135
|
+
| ChatGPT (subscription) | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
|
|
136
|
+
| OpenRouter (API billing) | Its own page, back to this computer (Pi's flow), when an app offers it (never by default) | Not yet | Not yet |
|
|
52
137
|
| Grok, Copilot (hidden) | Pi's flows | No | No |
|
|
53
138
|
| Where sign-ins are kept | `fileStore(path)`, sealed with Electron's `safeStorage` when given | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
|
|
54
139
|
|
|
@@ -57,46 +142,137 @@ listener on the computer the browser runs on, so it is desktop only: ChatGPT sen
|
|
|
57
142
|
`localhost:1455`, fixed for the client this signs in as. A web page can't call ChatGPT's model endpoint itself (it
|
|
58
143
|
doesn't answer other web pages), so a PWA's model calls go through the app's own server or relay.
|
|
59
144
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
145
|
+
## Catalogue and billing
|
|
146
|
+
|
|
147
|
+
`catalogue.json` holds each provider with its billing (`subscription`, `api`) and terms status (`allowed`, `grey`,
|
|
148
|
+
`partner`), a one-line reason and a source. The kit labels; your app decides what to offer
|
|
149
|
+
(`new Accounts({ offer: ['chatgpt'] })`). Without an explicit `offer`, only subscription sign-ins supported on this
|
|
150
|
+
platform are shown: OpenRouter is API-billed and never offered by default. An explicit list is not platform-filtered,
|
|
151
|
+
so choose from the table above. Show `billingWords(p)` next to every provider you list.
|
|
152
|
+
|
|
153
|
+
Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { Accounts, billingWords, offered } from '@byokit/accounts';
|
|
157
|
+
|
|
158
|
+
console.log(new Accounts().providers.map((p) => p.key)); // the default offer on a computer
|
|
159
|
+
for (const p of offered(['chatgpt', 'openrouter'])) console.log(`${p.name}: ${billingWords(p)}`);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
[ 'chatgpt' ]
|
|
164
|
+
ChatGPT: Uses your ChatGPT plan.
|
|
165
|
+
OpenRouter: Charged per use to your OpenRouter account, not a plan.
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Sign-in
|
|
169
|
+
|
|
170
|
+
On computers, the provider's own page by default. For ChatGPT, whose page returns to this computer's port 1455, the
|
|
171
|
+
kit listens there itself, so the tab shows your app's words (`new Accounts({ app: 'My App' })`) and only once they are
|
|
172
|
+
true. A code takes over when asked ("Having trouble?"), when the page never comes back, or when the port is taken by
|
|
173
|
+
another sign-in.
|
|
174
|
+
|
|
175
|
+
A 15-minute cap, nothing kept unless the engine can use it, and every failure is one plain sentence (`words.json`)
|
|
176
|
+
with a `why` for apps that word it themselves. `plan(member)` tells a work ChatGPT from a personal one.
|
|
177
|
+
|
|
178
|
+
## Sign-out
|
|
179
|
+
|
|
180
|
+
`logout(member, key)` attempts to revoke a ChatGPT token at OpenAI (`POST auth.openai.com/oauth/revoke`), then deletes
|
|
181
|
+
the local sign-in even if the revoke fails. A failed revoke rejects after local deletion; report it because the remote
|
|
182
|
+
sign-in may remain active.
|
|
183
|
+
|
|
184
|
+
Within one store instance, a refresh already in progress finishes first, so sign-out uses its rotated token. If a
|
|
185
|
+
cancelled sign-in finishes late, `onSignOutError` reports a failed revoke of its discarded credential (or it is logged
|
|
186
|
+
when no handler is set).
|
|
187
|
+
|
|
188
|
+
## One person, one store
|
|
189
|
+
|
|
190
|
+
`memoryStore()`, `fileStore(path)` (0600, the same shape as Pi's `auth.json`), `secureStore(SecureStore, name,
|
|
191
|
+
options?)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are serialized within a
|
|
192
|
+
store instance; `browserStore` also uses Web Locks across tabs for the same provider when available. Never a shared
|
|
193
|
+
fallback.
|
|
194
|
+
|
|
195
|
+
- **Browser**: browser storage is readable by scripts on your page: avoid untrusted scripts.
|
|
196
|
+
- **Phone**: pass `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }` as `options` (to every get, set
|
|
197
|
+
and delete) so tokens never migrate to a new device through an iCloud/iTunes backup; without it Expo's default
|
|
198
|
+
(`WHEN_UNLOCKED`) applies.
|
|
199
|
+
- **Reinstall**: iOS Keychain items survive an app reinstall under the same bundle id (Android data is gone). Apps that
|
|
200
|
+
must forget on reinstall keep a first-run marker outside the Keychain (e.g. `expo-file-system` or `AsyncStorage`)
|
|
201
|
+
and, when it is missing, call `accounts.logout(member, key)` for each offered key before first use, which revokes and
|
|
202
|
+
wipes.
|
|
203
|
+
- **Another engine**: using another engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override
|
|
204
|
+
`open(member)` with an engine whose `credentialStore` is made with `boundStore(member, engineStore)` and whose
|
|
205
|
+
`readCredential(id)` reads that store.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const accounts = new Accounts({
|
|
209
|
+
store: (member) => secureStore(SecureStore, `byokit.${member}`, { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }),
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Asking
|
|
214
|
+
|
|
215
|
+
`respond(member, { instructions, input, model?, onText?, signal? })` asks ChatGPT's own answers endpoint with the
|
|
216
|
+
member's sign-in, refreshed first when due, and returns the whole text (`onText` gets each piece as it streams; the
|
|
217
|
+
returned completion is authoritative). A limit or a lapsed sign-in is acted on as `failed()` does, then thrown as a
|
|
218
|
+
`ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
|
|
219
|
+
|
|
220
|
+
The whole question passes through: `input` takes the turns so far (messages, with `input_image` where the person
|
|
221
|
+
attached a picture), `tools` and `tool_choice` take the app's own function tools and built-ins (including
|
|
222
|
+
`image_generation`), `reasoning.effort` how hard the model thinks, and `text` how long the answer is with the shape it
|
|
223
|
+
must follow (`text.format`). With `tools` the result is the text with every output item; without, the plain text as
|
|
224
|
+
before. `onEvent` sees each tool call and output item as it streams:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const result = await accounts.respond(1, {
|
|
228
|
+
instructions: 'Answer briefly.',
|
|
229
|
+
input: 'What time is it in Norwich?',
|
|
230
|
+
tools: [{ type: 'function', name: 'get_time', description: 'The time somewhere.', parameters: { type: 'object', properties: { place: { type: 'string' } } } }],
|
|
231
|
+
onEvent: (e) => { if (e.type === 'function_call') console.log('calling', e.name, e.arguments); },
|
|
232
|
+
});
|
|
233
|
+
if (isFunctionCall(result.output[0])) {
|
|
234
|
+
const answer = await accounts.respond(1, {
|
|
235
|
+
instructions: 'Answer briefly.',
|
|
236
|
+
input: [
|
|
237
|
+
{ role: 'user', content: [{ type: 'input_text', text: 'What time is it in Norwich?' }] },
|
|
238
|
+
result.output[0],
|
|
239
|
+
{ type: 'function_call_output', call_id: result.output[0].call_id!, output: 'noon' },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
show(answer); // 'You did: noon' against the stand-in; the model's own sentence live
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
A web page can't call this endpoint itself (it answers no other web page): ask from the app's own server or over
|
|
247
|
+
`@byokit/link`.
|
|
248
|
+
|
|
249
|
+
## Limits
|
|
250
|
+
|
|
251
|
+
`failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that doesn't
|
|
252
|
+
include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable account;
|
|
253
|
+
`keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
|
|
254
|
+
|
|
255
|
+
## Isolation
|
|
256
|
+
|
|
257
|
+
Ambient discovery is off (no environment variable or credential file is ever consulted), and
|
|
258
|
+
`@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests. `decoy(root)` writes
|
|
259
|
+
only under the caller-supplied root; the caller owns its creation and cleanup.
|
|
260
|
+
|
|
261
|
+
## A stand-in OpenAI
|
|
262
|
+
|
|
263
|
+
`mockOpenAI()` from `@byokit/accounts/testing` (or, from a repo checkout, `node packages/accounts/src/testing/mock-openai.ts [port]`) answers device code, its
|
|
264
|
+
page where a person types the code, token exchange, refresh, revoke and streamed answers (echoing the question), so
|
|
265
|
+
tests and demos sign in and ask end to end with no account. Point the kit at it with
|
|
266
|
+
`new Accounts({ authBase, apiBase })`, as the [Quickstart](#quickstart) does.
|
|
267
|
+
|
|
268
|
+
## Links
|
|
269
|
+
|
|
270
|
+
- [byokit](../../README.md): every package and example
|
|
271
|
+
- [`examples/pwa`](../../examples/pwa) (browser sign-in) and [`examples/expo`](../../examples/expo) (React Native, iOS
|
|
272
|
+
and Android)
|
|
273
|
+
- [Conformance fixtures](../../fixtures/README.md)
|
|
274
|
+
- [CHANGELOG](CHANGELOG.md)
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](../../NOTICE).
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,7 +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
|
+
import { ResponseError, type Ask, type ResponseResult, type ResponseTool } from './responses.ts';
|
|
5
5
|
import { type EndingStore } from './stores.ts';
|
|
6
6
|
import { type Why } from './words.ts';
|
|
7
7
|
/** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
|
|
@@ -130,8 +130,15 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
|
|
|
130
130
|
* or null for an error that is not about the account; `network` changes nothing. */
|
|
131
131
|
/** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
|
|
132
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
|
-
|
|
133
|
+
* ResponseError with the words to show. Without `tools` the answer is the plain text, as before: pass `input` as
|
|
134
|
+
* words or as turns (messages with `input_image`, then the `function_call` with its `function_call_output`). With
|
|
135
|
+
* `tools` it is the text with every output item, and `onEvent` sees each tool call as it lands. */
|
|
136
|
+
respond(member: M, ask: Ask & {
|
|
137
|
+
tools?: undefined;
|
|
138
|
+
}): Promise<string>;
|
|
139
|
+
respond(member: M, ask: Ask & {
|
|
140
|
+
tools: ResponseTool[];
|
|
141
|
+
}): Promise<ResponseResult>;
|
|
135
142
|
failed(member: M, key: string, error: string | ResponseError): Promise<{
|
|
136
143
|
kind: Kind;
|
|
137
144
|
until: number;
|
package/dist/accounts.js
CHANGED
|
@@ -216,13 +216,6 @@ export class Accounts {
|
|
|
216
216
|
const r = this.rests.get(`${member}:${key}`);
|
|
217
217
|
return r && r.until > Date.now() ? r.until : 0;
|
|
218
218
|
}
|
|
219
|
-
/** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
|
|
220
|
-
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
221
|
-
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
|
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
219
|
async respond(member, ask) {
|
|
227
220
|
const key = 'chatgpt';
|
|
228
221
|
const p = this.offer(key);
|
|
@@ -243,7 +236,8 @@ export class Accounts {
|
|
|
243
236
|
if (!access || c?.type !== 'oauth')
|
|
244
237
|
throw new ResponseError(say('status.signedOut', { name: p.name }), 'signed_out');
|
|
245
238
|
try {
|
|
246
|
-
|
|
239
|
+
const base = { ...ask, access, accountId: String(c.accountId ?? ''), model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch };
|
|
240
|
+
return ask.tools ? await respond({ ...base, tools: ask.tools }) : await respond({ ...base, tools: undefined });
|
|
247
241
|
}
|
|
248
242
|
catch (e) {
|
|
249
243
|
if (e instanceof ResponseError && e.kind && e.kind !== 'network') {
|
package/dist/portable.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type L
|
|
|
2
2
|
export { PROVIDERS, offered, provider, type Billing, 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
|
+
export { ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
|
|
6
6
|
export { browserStore, memoryStore, recordStore, secureStore, type SecureStoreLike } from './stores.ts';
|
|
7
7
|
export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
|
package/dist/portable.js
CHANGED
|
@@ -4,6 +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
|
+
export { ResponseError, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
|
|
8
8
|
export { browserStore, memoryStore, recordStore, secureStore } from "./stores.js";
|
|
9
9
|
export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } from "./words.js";
|
package/dist/responses.d.ts
CHANGED
|
@@ -12,28 +12,182 @@ export declare function limitResponse(status: number, body: string, now?: number
|
|
|
12
12
|
until: number | null;
|
|
13
13
|
message: string;
|
|
14
14
|
};
|
|
15
|
-
/**
|
|
15
|
+
/** One piece of a message the model is given: words, a picture, or anything else the endpoint accepts. */
|
|
16
|
+
export type ResponseInputContent = {
|
|
17
|
+
type: 'input_text';
|
|
18
|
+
text: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'input_image';
|
|
21
|
+
image_url?: string;
|
|
22
|
+
file_id?: string;
|
|
23
|
+
detail?: 'auto' | 'low' | 'high';
|
|
24
|
+
} | {
|
|
25
|
+
type: string;
|
|
26
|
+
[k: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
/** What the model is told, in turn: a message (one turn of the conversation), a call it made, or a tool's answer. */
|
|
29
|
+
export type ResponseInputItem = {
|
|
30
|
+
type?: 'message';
|
|
31
|
+
role: 'user' | 'assistant' | 'system' | 'developer';
|
|
32
|
+
content: string | ResponseInputContent[];
|
|
33
|
+
} | {
|
|
34
|
+
type: 'function_call';
|
|
35
|
+
call_id?: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
name: string;
|
|
38
|
+
arguments: string;
|
|
39
|
+
} | {
|
|
40
|
+
type: 'function_call_output';
|
|
41
|
+
call_id: string;
|
|
42
|
+
output: string;
|
|
43
|
+
} | {
|
|
44
|
+
type: string;
|
|
45
|
+
[k: string]: unknown;
|
|
46
|
+
};
|
|
47
|
+
/** A tool the model may call: one of the app's functions, or a built-in such as image generation. */
|
|
48
|
+
export type ResponseTool = {
|
|
49
|
+
type: 'function';
|
|
50
|
+
name: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
parameters?: Record<string, unknown> | null;
|
|
53
|
+
strict?: boolean;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'image_generation';
|
|
56
|
+
model?: string;
|
|
57
|
+
size?: string;
|
|
58
|
+
quality?: string;
|
|
59
|
+
[k: string]: unknown;
|
|
60
|
+
} | {
|
|
61
|
+
type: string;
|
|
62
|
+
[k: string]: unknown;
|
|
63
|
+
};
|
|
64
|
+
/** Which tool the model must use: any, a named function, none, or whatever it wants. */
|
|
65
|
+
export type ResponseToolChoice = 'auto' | 'required' | 'none' | {
|
|
66
|
+
type: 'function';
|
|
67
|
+
name: string;
|
|
68
|
+
} | {
|
|
69
|
+
type: string;
|
|
70
|
+
[k: string]: unknown;
|
|
71
|
+
};
|
|
72
|
+
/** How hard the model thinks. Default: none, as before. */
|
|
73
|
+
export type ResponseReasoning = {
|
|
74
|
+
effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
75
|
+
[k: string]: unknown;
|
|
76
|
+
};
|
|
77
|
+
/** The shape of the answer: how wordy, and optionally a schema it must follow. */
|
|
78
|
+
export type ResponseTextFormat = {
|
|
79
|
+
type: 'text';
|
|
80
|
+
} | {
|
|
81
|
+
type: 'json_object';
|
|
82
|
+
} | {
|
|
83
|
+
type: 'json_schema';
|
|
84
|
+
name: string;
|
|
85
|
+
schema: Record<string, unknown>;
|
|
86
|
+
strict?: boolean;
|
|
87
|
+
} | {
|
|
88
|
+
type: string;
|
|
89
|
+
[k: string]: unknown;
|
|
90
|
+
};
|
|
91
|
+
export type ResponseText = {
|
|
92
|
+
verbosity?: 'low' | 'medium' | 'high';
|
|
93
|
+
format?: ResponseTextFormat;
|
|
94
|
+
[k: string]: unknown;
|
|
95
|
+
};
|
|
96
|
+
/** One item of the model's answer: a message, a function call, or anything else the endpoint returns. */
|
|
97
|
+
export type ResponseOutputMessage = {
|
|
98
|
+
type: 'message';
|
|
99
|
+
id?: string;
|
|
100
|
+
role?: string;
|
|
101
|
+
content?: {
|
|
102
|
+
type: 'output_text' | 'refusal';
|
|
103
|
+
text: string;
|
|
104
|
+
annotations?: unknown[];
|
|
105
|
+
}[];
|
|
106
|
+
[k: string]: unknown;
|
|
107
|
+
};
|
|
108
|
+
export type ResponseFunctionCall = {
|
|
109
|
+
type: 'function_call';
|
|
110
|
+
id?: string;
|
|
111
|
+
call_id?: string;
|
|
112
|
+
name: string;
|
|
113
|
+
arguments: string;
|
|
114
|
+
[k: string]: unknown;
|
|
115
|
+
};
|
|
116
|
+
export type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionCall | {
|
|
117
|
+
type: 'reasoning';
|
|
118
|
+
[k: string]: unknown;
|
|
119
|
+
} | {
|
|
120
|
+
type: string;
|
|
121
|
+
[k: string]: unknown;
|
|
122
|
+
};
|
|
123
|
+
/** Whether an output item is a function call, so an app can answer it with a `function_call_output` turn. */
|
|
124
|
+
export declare const isFunctionCall: (item: ResponseOutputItem) => item is ResponseFunctionCall;
|
|
125
|
+
/** The model's answer: the text (`onText` saw it piece by piece) and every output item. */
|
|
126
|
+
export type ResponseResult = {
|
|
127
|
+
text: string;
|
|
128
|
+
output: ResponseOutputItem[];
|
|
129
|
+
};
|
|
130
|
+
/** What streams besides the words: each text piece, each tool call as it builds and lands, and each output item. */
|
|
131
|
+
export type ResponseStreamEvent = {
|
|
132
|
+
type: 'text_delta';
|
|
133
|
+
delta: string;
|
|
134
|
+
} | {
|
|
135
|
+
type: 'function_call_delta';
|
|
136
|
+
name?: string;
|
|
137
|
+
callId?: string;
|
|
138
|
+
delta: string;
|
|
139
|
+
} | {
|
|
140
|
+
type: 'function_call';
|
|
141
|
+
name: string;
|
|
142
|
+
arguments: string;
|
|
143
|
+
callId?: string;
|
|
144
|
+
} | {
|
|
145
|
+
type: 'output_item';
|
|
146
|
+
item: ResponseOutputItem;
|
|
147
|
+
};
|
|
148
|
+
/** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text,
|
|
149
|
+
* `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
|
|
16
150
|
* An error event throws a ResponseError. */
|
|
17
|
-
export declare function sseReader(onText?: (delta: string) => void): {
|
|
151
|
+
export declare function sseReader(onText?: (delta: string) => void, onEvent?: (event: ResponseStreamEvent) => void): {
|
|
18
152
|
push(chunk: string): void;
|
|
19
153
|
end(): string;
|
|
154
|
+
result(): ResponseResult;
|
|
20
155
|
};
|
|
21
156
|
export type Ask = {
|
|
22
157
|
/** What the model is told to be. */
|
|
23
158
|
instructions: string;
|
|
24
|
-
/** The person's words
|
|
25
|
-
|
|
159
|
+
/** The person's words, or the turns so far: messages (with `input_image` where the person attached one) and, after a
|
|
160
|
+
* tool call, the `function_call` with its `function_call_output`. */
|
|
161
|
+
input: string | ResponseInputItem[];
|
|
26
162
|
/** Default: the provider's strong model in the catalogue. */
|
|
27
163
|
model?: string;
|
|
164
|
+
/** The tools the model may call. Passed, the result carries the output items next to the text. */
|
|
165
|
+
tools?: ResponseTool[];
|
|
166
|
+
/** Which tool the model must use. Default: whatever it wants. */
|
|
167
|
+
tool_choice?: ResponseToolChoice;
|
|
168
|
+
/** How hard the model thinks. Default: none. */
|
|
169
|
+
reasoning?: ResponseReasoning;
|
|
170
|
+
/** How wordy the answer is, and the schema it must follow. Default: low verbosity, free text. */
|
|
171
|
+
text?: ResponseText;
|
|
28
172
|
/** Each piece of the answer as it streams. */
|
|
29
173
|
onText?: (delta: string) => void;
|
|
174
|
+
/** Each tool call and output item as it lands. */
|
|
175
|
+
onEvent?: (event: ResponseStreamEvent) => void;
|
|
30
176
|
signal?: AbortSignal;
|
|
31
177
|
};
|
|
32
|
-
|
|
33
|
-
export declare function respond(o: Ask & {
|
|
178
|
+
type Access = {
|
|
34
179
|
access: string;
|
|
35
180
|
accountId: string;
|
|
36
181
|
model: string;
|
|
37
182
|
base?: string;
|
|
38
183
|
fetch?: typeof fetch;
|
|
184
|
+
};
|
|
185
|
+
/** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works.
|
|
186
|
+
* Without `tools` the answer is the plain text, as before; with `tools` it is the text with every output item. */
|
|
187
|
+
export declare function respond(o: Ask & Access & {
|
|
188
|
+
tools?: undefined;
|
|
39
189
|
}): Promise<string>;
|
|
190
|
+
export declare function respond(o: Ask & Access & {
|
|
191
|
+
tools: ResponseTool[];
|
|
192
|
+
}): Promise<ResponseResult>;
|
|
193
|
+
export {};
|
package/dist/responses.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// One question to ChatGPT, answered as it streams, with fetch alone: what a phone or browser app asks the model with
|
|
2
2
|
// the sign-in it holds. Rules are the shared fixtures (sse.json, limit-responses.json). A fetch that can't stream (React
|
|
3
3
|
// Native's own) still works: the whole answer arrives at once. Expo's `fetch` from 'expo/fetch' streams.
|
|
4
|
+
//
|
|
5
|
+
// The pass-through is complete but typed: a message array (multi-turn, with `input_image` and `function_call_output`
|
|
6
|
+
// turns), `tools` and `tool_choice` (function tools and built-ins, including `image_generation`), `reasoning.effort`,
|
|
7
|
+
// and `text.verbosity` with the `text.format` schema. Without `tools` the answer is the plain text, as before; with
|
|
8
|
+
// `tools` the result carries the output items (`function_call` and the rest) next to the text.
|
|
4
9
|
import { classify } from "./limits.js";
|
|
5
10
|
const limitKind = (code) => code === 'usage_not_included' ? 'not_included'
|
|
6
11
|
: /^(usage_limit_reached|rate_limit_exceeded)$/.test(code) ? 'rate_limit' : null;
|
|
@@ -28,11 +33,32 @@ export function limitResponse(status, body, now = Date.now()) {
|
|
|
28
33
|
const kind = status === 401 || status === 403 ? 'signed_out' : [500, 502, 503, 504].includes(status) ? 'overloaded' : null;
|
|
29
34
|
return { kind, until: null, message: (typeof err.message === 'string' && err.message) || body || 'Request failed' };
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
37
|
+
const itemKey = (item, fallback) => typeof item.call_id === 'string' ? item.call_id : typeof item.id === 'string' ? item.id : `${String(item.type)}:${String(fallback)}`;
|
|
38
|
+
/** Whether an output item is a function call, so an app can answer it with a `function_call_output` turn. */
|
|
39
|
+
export const isFunctionCall = (item) => isRecord(item) && item.type === 'function_call' && typeof item.name === 'string' && typeof item.arguments === 'string';
|
|
40
|
+
/** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text,
|
|
41
|
+
* `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
|
|
32
42
|
* An error event throws a ResponseError. */
|
|
33
|
-
export function sseReader(onText) {
|
|
43
|
+
export function sseReader(onText, onEvent) {
|
|
34
44
|
let buffer = '', text = '', completed;
|
|
35
|
-
let done = false;
|
|
45
|
+
let done = false, finished;
|
|
46
|
+
const output = [];
|
|
47
|
+
const emitted = new Set();
|
|
48
|
+
const calls = new Map();
|
|
49
|
+
const callKey = (e) => typeof e.item_id === 'string' ? e.item_id : `index:${String(e.output_index ?? 0)}`;
|
|
50
|
+
const land = (item, fallback) => {
|
|
51
|
+
if (!isRecord(item) || typeof item.type !== 'string')
|
|
52
|
+
return;
|
|
53
|
+
const key = itemKey(item, fallback);
|
|
54
|
+
if (emitted.has(key))
|
|
55
|
+
return;
|
|
56
|
+
emitted.add(key);
|
|
57
|
+
output.push(item);
|
|
58
|
+
onEvent?.({ type: 'output_item', item: item });
|
|
59
|
+
if (item.type === 'function_call' && typeof item.name === 'string' && typeof item.arguments === 'string')
|
|
60
|
+
onEvent?.({ type: 'function_call', name: item.name, arguments: item.arguments, callId: typeof item.call_id === 'string' ? item.call_id : undefined });
|
|
61
|
+
};
|
|
36
62
|
const event = (block) => {
|
|
37
63
|
const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
|
|
38
64
|
if (!data || data === '[DONE]')
|
|
@@ -47,11 +73,38 @@ export function sseReader(onText) {
|
|
|
47
73
|
if (e.type === 'response.output_text.delta' && typeof e.delta === 'string') {
|
|
48
74
|
text += e.delta;
|
|
49
75
|
onText?.(e.delta);
|
|
76
|
+
onEvent?.({ type: 'text_delta', delta: e.delta });
|
|
77
|
+
}
|
|
78
|
+
if (e.type === 'response.output_item.added' && isRecord(e.item)) {
|
|
79
|
+
const key = typeof e.item.call_id === 'string' ? e.item.call_id : callKey(e);
|
|
80
|
+
const at = calls.get(key) ?? { args: '' };
|
|
81
|
+
if (typeof e.item.name === 'string')
|
|
82
|
+
at.name = e.item.name;
|
|
83
|
+
if (typeof e.item.call_id === 'string')
|
|
84
|
+
at.callId = e.item.call_id;
|
|
85
|
+
calls.set(key, at);
|
|
50
86
|
}
|
|
51
|
-
if (e.type === 'response.
|
|
87
|
+
if (e.type === 'response.function_call_arguments.delta' && typeof e.delta === 'string') {
|
|
88
|
+
const at = calls.get(callKey(e)) ?? { args: '' };
|
|
89
|
+
at.args += e.delta;
|
|
90
|
+
calls.set(callKey(e), at);
|
|
91
|
+
onEvent?.({ type: 'function_call_delta', name: at.name, callId: at.callId, delta: e.delta });
|
|
92
|
+
}
|
|
93
|
+
if (e.type === 'response.output_item.done' && isRecord(e.item)) {
|
|
94
|
+
if (e.item.type === 'function_call' && typeof e.item.arguments !== 'string') {
|
|
95
|
+
const at = calls.get(callKey(e));
|
|
96
|
+
if (at && at.args)
|
|
97
|
+
e.item = { ...e.item, arguments: at.args };
|
|
98
|
+
}
|
|
99
|
+
land(e.item, e.output_index ?? 0);
|
|
100
|
+
}
|
|
101
|
+
if (e.type === 'response.completed' || e.type === 'response.incomplete') {
|
|
52
102
|
done = true;
|
|
53
|
-
if (Array.isArray(e.response?.output))
|
|
103
|
+
if (Array.isArray(e.response?.output)) {
|
|
104
|
+
for (const [i, item] of e.response.output.entries())
|
|
105
|
+
land(item, i);
|
|
54
106
|
completed = e.response.output.flatMap((o) => o?.content ?? []).filter((c) => c?.type === 'output_text').map((c) => c.text ?? '').join('');
|
|
107
|
+
}
|
|
55
108
|
}
|
|
56
109
|
const failed = e.type === 'error' ? e : e.type === 'response.failed' ? e.response?.error : undefined;
|
|
57
110
|
if (failed) {
|
|
@@ -66,23 +119,33 @@ export function sseReader(onText) {
|
|
|
66
119
|
for (const b of blocks)
|
|
67
120
|
event(b);
|
|
68
121
|
};
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
end() {
|
|
122
|
+
const finish = () => {
|
|
123
|
+
if (!finished) {
|
|
72
124
|
drain(true);
|
|
73
125
|
if (!done)
|
|
74
126
|
throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
|
|
127
|
+
let whole = text;
|
|
75
128
|
if (completed !== undefined && completed !== text) {
|
|
76
129
|
if (completed.startsWith(text))
|
|
77
130
|
onText?.(completed.slice(text.length));
|
|
78
|
-
|
|
131
|
+
whole = completed;
|
|
79
132
|
}
|
|
80
|
-
|
|
81
|
-
}
|
|
133
|
+
finished = { text: whole, output };
|
|
134
|
+
}
|
|
135
|
+
return finished;
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
push(chunk) { buffer += chunk; drain(false); },
|
|
139
|
+
end() { return finish().text; },
|
|
140
|
+
result() { return finish(); },
|
|
82
141
|
};
|
|
83
142
|
}
|
|
84
|
-
/** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works. */
|
|
85
143
|
export async function respond(o) {
|
|
144
|
+
const input = typeof o.input === 'string'
|
|
145
|
+
? [{ role: 'user', content: [{ type: 'input_text', text: o.input }] }]
|
|
146
|
+
: o.input;
|
|
147
|
+
const { verbosity = 'low', format, ...textRest } = o.text ?? {};
|
|
148
|
+
const { effort = 'none', ...reasoningRest } = o.reasoning ?? {};
|
|
86
149
|
const res = await (o.fetch ?? fetch)(`${o.base ?? 'https://chatgpt.com/backend-api'}/codex/responses`, {
|
|
87
150
|
method: 'POST', signal: o.signal,
|
|
88
151
|
headers: {
|
|
@@ -90,16 +153,18 @@ export async function respond(o) {
|
|
|
90
153
|
'chatgpt-account-id': o.accountId, 'OpenAI-Beta': 'responses=experimental', originator: 'byokit',
|
|
91
154
|
},
|
|
92
155
|
body: JSON.stringify({
|
|
93
|
-
model: o.model, store: false, stream: true, instructions: o.instructions,
|
|
94
|
-
|
|
95
|
-
|
|
156
|
+
model: o.model, store: false, stream: true, instructions: o.instructions, input,
|
|
157
|
+
...(o.tools ? { tools: o.tools } : {}),
|
|
158
|
+
...(o.tool_choice !== undefined ? { tool_choice: o.tool_choice } : {}),
|
|
159
|
+
text: { verbosity, ...(format ? { format } : {}), ...textRest },
|
|
160
|
+
reasoning: { effort, ...reasoningRest },
|
|
96
161
|
}),
|
|
97
162
|
});
|
|
98
163
|
if (!res.ok) {
|
|
99
164
|
const e = limitResponse(res.status, await res.text().catch(() => ''));
|
|
100
165
|
throw new ResponseError(e.message, e.kind, e.until ?? 0);
|
|
101
166
|
}
|
|
102
|
-
const reader = sseReader(o.onText);
|
|
167
|
+
const reader = sseReader(o.onText, o.onEvent);
|
|
103
168
|
const body = res.body;
|
|
104
169
|
if (body?.getReader && typeof TextDecoder !== 'undefined') {
|
|
105
170
|
const r = body.getReader();
|
|
@@ -110,5 +175,5 @@ export async function respond(o) {
|
|
|
110
175
|
else {
|
|
111
176
|
reader.push(await res.text()); // a fetch that can't stream: the whole answer at once
|
|
112
177
|
}
|
|
113
|
-
return reader.end();
|
|
178
|
+
return o.tools ? reader.result() : reader.end();
|
|
114
179
|
}
|
|
@@ -100,7 +100,41 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
|
|
|
100
100
|
}
|
|
101
101
|
if (![...state.live].some((r) => accessOf.get(r) === bearer) || req.headers['chatgpt-account-id'] !== 'acct-1')
|
|
102
102
|
return send(401, { error: { message: 'Provided authentication token is expired. Please try signing in again.' } });
|
|
103
|
-
const
|
|
103
|
+
const asked = json();
|
|
104
|
+
const turns = Array.isArray(asked.input) ? asked.input : [];
|
|
105
|
+
const said = [];
|
|
106
|
+
let answered;
|
|
107
|
+
for (const item of turns) {
|
|
108
|
+
if (item?.type === 'function_call_output') {
|
|
109
|
+
answered ??= String(item.output ?? '');
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const content = typeof item?.content === 'string' ? [{ type: 'input_text', text: item.content }] : Array.isArray(item?.content) ? item.content : [];
|
|
113
|
+
for (const part of content)
|
|
114
|
+
if (part?.type === 'input_text' && typeof part.text === 'string')
|
|
115
|
+
said.push(part.text);
|
|
116
|
+
}
|
|
117
|
+
const words = said.join(' ');
|
|
118
|
+
const schema = asked.text?.format?.type === 'json_schema';
|
|
119
|
+
const text = answered !== undefined ? `You did: ${answered}`
|
|
120
|
+
: schema ? JSON.stringify({ echo: words ? `You said: ${words}` : 'You said nothing' })
|
|
121
|
+
: `You said: ${words}`;
|
|
122
|
+
const called = Array.isArray(asked.tools) ? asked.tools.filter((t) => t?.type === 'function') : [];
|
|
123
|
+
if (called.length > 0 && answered === undefined) {
|
|
124
|
+
// A tool turn: the model calls the first function tool, streamed as argument deltas and one finished item,
|
|
125
|
+
// then the completion with the output list. The app answers with a `function_call_output` turn next.
|
|
126
|
+
const name = String(called[0].name ?? 'tool');
|
|
127
|
+
const args = JSON.stringify({ input: words });
|
|
128
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
129
|
+
res.write(`event: response.output_item.added\ndata: ${JSON.stringify({ type: 'response.output_item.added', output_index: 0, item: { type: 'function_call', call_id: 'call_1', name, arguments: '' } })}\n\n`);
|
|
130
|
+
for (const delta of args.match(/[\s\S]{1,4}/g) ?? []) {
|
|
131
|
+
res.write(`event: response.function_call_arguments.delta\ndata: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'call_1', delta })}\n\n`);
|
|
132
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
133
|
+
}
|
|
134
|
+
const item = { type: 'function_call', call_id: 'call_1', name, arguments: args };
|
|
135
|
+
res.write(`event: response.output_item.done\ndata: ${JSON.stringify({ type: 'response.output_item.done', output_index: 0, item })}\n\n`);
|
|
136
|
+
return res.end(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed', output: [item] } })}\n\ndata: [DONE]\n\n`);
|
|
137
|
+
}
|
|
104
138
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
105
139
|
for (const delta of text.match(/[\s\S]{1,4}/g) ?? []) {
|
|
106
140
|
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta })}\n\n`);
|