@byokit/accounts 0.4.1 → 0.6.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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.0 (2026-09-30)
6
+
7
+ - `respond` and `Accounts.respond` accept `originator` (or set it once on `Accounts`): the app's own originator header value. Default: 'byokit', as before.
8
+ - FIX: a garbled streamed answer no longer arrives as an empty string with HTTP 200: a data line the parser cannot read now throws a ResponseError the app can show.
9
+ - FIX: a stream that ends with no words, no completed answer and no tool calls now throws instead of resolving to an empty string.
10
+ - FIX: the answer is the words as they streamed in; the completed envelope is only used when nothing streamed. Apps whose completed envelope carries no text no longer see their streamed words replaced by an empty answer.
11
+ - FIX: streamed answers split on bare-CR line endings too, so a backend that separates events with carriage returns no longer yields an empty answer.
12
+
13
+ - `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.
14
+
5
15
  ## 0.4.1 (2026-09-29)
6
16
 
7
17
  - 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
- # @byokit/accounts
1
+ <h1 align="center">@byokit/accounts</h1>
2
2
 
3
- Sign in with the AI plan you already pay for (ChatGPT on every platform; OpenRouter on computers when an app offers it
4
- (API billing, never by default); Grok and GitHub Copilot hidden by default), inside your own app, into your app's own store: on a computer (Node, Electron), in a browser
5
- (a PWA, Electron's renderer) and on a
6
- phone (React Native and Expo, iOS and Android). One import; your bundler picks the platform's side
7
- (`package.json`'s `react-native` and `browser` conditions).
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 &quot;byokit in a browser&quot;, signed out: &quot;ChatGPT isn't signed in yet.&quot; 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: &quot;Signing in to ChatGPT…&quot;, &quot;On the ChatGPT page, type this code:&quot; 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: &quot;ChatGPT is connected.&quot;, 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
+ [![npm](https://img.shields.io/npm/v/@byokit/accounts?style=flat&label=)](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 the same `Accounts` signs in to ChatGPT by device code with `fetch` alone (Pi's flows need
24
- Node), into the phone's secure storage or the browser's IndexedDB:
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 finishes
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) and
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 (API billing, never by default) | Not yet | Not yet |
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
- - **Catalogue** (`catalogue.json`): each provider with its billing (`subscription`, `api`) and terms status (`allowed`,
61
- `grey`, `partner`), a one-line reason and a source. The kit labels; your app decides what to offer
62
- (`new Accounts({ offer: ['chatgpt'] })`). Without an explicit `offer`, only subscription sign-ins supported on this
63
- platform are shown: OpenRouter is API-billed and never offered by default. An explicit list is not
64
- platform-filtered, so choose from the table above. Show `billingWords(p)` next to every provider you list.
65
- Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
66
- - **Sign-in**: on computers, the provider's own page by default. For ChatGPT, whose page returns to this computer's
67
- port 1455, the kit listens there itself, so the tab shows your app's words (`new Accounts({ app: 'My App' })`) and only once they are
68
- true. A code takes over when asked ("Having trouble?"), when the page never comes back, or when the port is taken by
69
- another sign-in. A 15-minute cap, nothing kept unless the engine can use it, and every failure is one plain sentence
70
- (`words.json`) with a `why` for apps that word it themselves. `plan(member)` tells a work ChatGPT from a personal one.
71
- - **Sign-out**: `logout(member, key)` attempts to revoke a ChatGPT token at OpenAI (`POST auth.openai.com/oauth/revoke`),
72
- then deletes the local sign-in even if the revoke fails. A failed revoke rejects after local deletion; report it because
73
- the remote sign-in may remain active. Within one store instance, a refresh already in progress finishes first, so
74
- sign-out uses its rotated token. If a cancelled sign-in finishes late, `onSignOutError` reports a failed revoke of its
75
- discarded credential (or it is logged when no handler is set).
76
- - **One person, one store**: `memoryStore()`, `fileStore(path)` (0600, the same shape as Pi's `auth.json`),
77
- `secureStore(SecureStore, name, options?)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are
78
- serialized within a store instance; `browserStore` also uses Web Locks across tabs for the same provider when available.
79
- Never a shared fallback. Browser storage is readable by scripts on your page: avoid untrusted scripts. On a phone, pass
80
- `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }` as `options` (to every get, set and delete) so
81
- tokens never migrate to a new device through an iCloud/iTunes backup; without it Expo's default (`WHEN_UNLOCKED`)
82
- applies. iOS Keychain items survive an app reinstall under the same bundle id (Android data is gone): apps that must
83
- forget on reinstall keep a first-run marker outside the Keychain (e.g. `expo-file-system` or `AsyncStorage`) and, when
84
- it is missing, call `accounts.logout(member, key)` for each offered key before first use, which revokes and wipes. Using another
85
- engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)` with an engine whose
86
- `credentialStore` is made with `boundStore(member, engineStore)` and whose `readCredential(id)` reads that store.
87
- - **Asking**: `respond(member, { instructions, input, model?, onText?, signal? })` asks ChatGPT's own answers endpoint
88
- with the member's sign-in, refreshed first when due, and returns the whole text (`onText` gets each piece as it
89
- streams; the returned completion is authoritative). A limit or a lapsed sign-in is acted on as `failed()` does, then
90
- thrown as a `ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
91
- A web page can't call this endpoint itself (it answers no other web page): ask from the app's own server or over
92
- `@byokit/link`.
93
- - **Limits**: `failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that
94
- doesn't include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable
95
- account; `keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
96
- - **Isolation**: ambient discovery is off (no environment variable or credential file is ever consulted), and
97
- `@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests. `decoy(root)`
98
- writes only under the caller-supplied root; the caller owns its creation and cleanup.
99
- - **A stand-in OpenAI**: `mockOpenAI()` from `@byokit/accounts/testing` (or `node .../testing/mock-openai.ts [port]`)
100
- answers device code, its page where a person types the code, token exchange, refresh, revoke and streamed answers
101
- (echoing the question), so tests and demos sign in and ask end to end with no account. Point the kit at it with
102
- `new Accounts({ authBase, apiBase })`.
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).
@@ -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`). */
@@ -67,6 +67,8 @@ export type AccountsOptions<M extends Member = Member> = {
67
67
  apiBase?: string;
68
68
  /** The fetch `respond` asks with: one that streams on a phone (Expo's `expo/fetch`). Default: the platform's. */
69
69
  fetch?: typeof fetch;
70
+ /** The originator header `respond` sends. Default: 'byokit'. */
71
+ originator?: string;
70
72
  };
71
73
  /** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
72
74
  export declare function planOf(access: string): {
@@ -130,8 +132,15 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
130
132
  * or null for an error that is not about the account; `network` changes nothing. */
131
133
  /** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
132
134
  * 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
+ * ResponseError with the words to show. Without `tools` the answer is the plain text, as before: pass `input` as
136
+ * words or as turns (messages with `input_image`, then the `function_call` with its `function_call_output`). With
137
+ * `tools` it is the text with every output item, and `onEvent` sees each tool call as it lands. */
138
+ respond(member: M, ask: Ask & {
139
+ tools?: undefined;
140
+ }): Promise<string>;
141
+ respond(member: M, ask: Ask & {
142
+ tools: ResponseTool[];
143
+ }): Promise<ResponseResult>;
135
144
  failed(member: M, key: string, error: string | ResponseError): Promise<{
136
145
  kind: Kind;
137
146
  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
- return await respond({ ...ask, access, accountId: String(c.accountId ?? ''), model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch });
239
+ const base = { ...ask, access, accountId: String(c.accountId ?? ''), model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch, originator: ask.originator ?? this.opts.originator };
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') {
@@ -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";
@@ -12,28 +12,186 @@ export declare function limitResponse(status: number, body: string, now?: number
12
12
  until: number | null;
13
13
  message: string;
14
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): {
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.
150
+ * Events split on any blank line (LF, CRLF or bare CR). A data line that is not JSON throws a ResponseError;
151
+ * a stream ending with nothing to show throws too. The text is the streamed deltas; the completed envelope
152
+ * only fills in when no deltas arrived. An error event throws a ResponseError. */
153
+ export declare function sseReader(onText?: (delta: string) => void, onEvent?: (event: ResponseStreamEvent) => void): {
18
154
  push(chunk: string): void;
19
155
  end(): string;
156
+ result(): ResponseResult;
20
157
  };
21
158
  export type Ask = {
22
159
  /** What the model is told to be. */
23
160
  instructions: string;
24
- /** The person's words. */
25
- input: string;
161
+ /** The person's words, or the turns so far: messages (with `input_image` where the person attached one) and, after a
162
+ * tool call, the `function_call` with its `function_call_output`. */
163
+ input: string | ResponseInputItem[];
26
164
  /** Default: the provider's strong model in the catalogue. */
27
165
  model?: string;
166
+ /** The tools the model may call. Passed, the result carries the output items next to the text. */
167
+ tools?: ResponseTool[];
168
+ /** Which tool the model must use. Default: whatever it wants. */
169
+ tool_choice?: ResponseToolChoice;
170
+ /** How hard the model thinks. Default: none. */
171
+ reasoning?: ResponseReasoning;
172
+ /** How wordy the answer is, and the schema it must follow. Default: low verbosity, free text. */
173
+ text?: ResponseText;
28
174
  /** Each piece of the answer as it streams. */
29
175
  onText?: (delta: string) => void;
176
+ /** Each tool call and output item as it lands. */
177
+ onEvent?: (event: ResponseStreamEvent) => void;
30
178
  signal?: AbortSignal;
179
+ /** The app's own originator header value. Default: 'byokit'. */
180
+ originator?: string;
31
181
  };
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 & {
182
+ type Access = {
34
183
  access: string;
35
184
  accountId: string;
36
185
  model: string;
37
186
  base?: string;
38
187
  fetch?: typeof fetch;
188
+ };
189
+ /** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works.
190
+ * Without `tools` the answer is the plain text, as before; with `tools` it is the text with every output item. */
191
+ export declare function respond(o: Ask & Access & {
192
+ tools?: undefined;
39
193
  }): Promise<string>;
194
+ export declare function respond(o: Ask & Access & {
195
+ tools: ResponseTool[];
196
+ }): Promise<ResponseResult>;
197
+ 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,34 @@ 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
- /** 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) {
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.
42
+ * Events split on any blank line (LF, CRLF or bare CR). A data line that is not JSON throws a ResponseError;
43
+ * a stream ending with nothing to show throws too. The text is the streamed deltas; the completed envelope
44
+ * only fills in when no deltas arrived. An error event throws a ResponseError. */
45
+ export function sseReader(onText, onEvent) {
34
46
  let buffer = '', text = '', completed;
35
- let done = false;
47
+ let done = false, finished;
48
+ const output = [];
49
+ const emitted = new Set();
50
+ const calls = new Map();
51
+ const callKey = (e) => typeof e.item_id === 'string' ? e.item_id : `index:${String(e.output_index ?? 0)}`;
52
+ const land = (item, fallback) => {
53
+ if (!isRecord(item) || typeof item.type !== 'string')
54
+ return;
55
+ const key = itemKey(item, fallback);
56
+ if (emitted.has(key))
57
+ return;
58
+ emitted.add(key);
59
+ output.push(item);
60
+ onEvent?.({ type: 'output_item', item: item });
61
+ if (item.type === 'function_call' && typeof item.name === 'string' && typeof item.arguments === 'string')
62
+ onEvent?.({ type: 'function_call', name: item.name, arguments: item.arguments, callId: typeof item.call_id === 'string' ? item.call_id : undefined });
63
+ };
36
64
  const event = (block) => {
37
65
  const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
38
66
  if (!data || data === '[DONE]')
@@ -42,16 +70,43 @@ export function sseReader(onText) {
42
70
  e = JSON.parse(data);
43
71
  }
44
72
  catch {
45
- return;
73
+ throw new ResponseError("ChatGPT's answer could not be read.", null);
46
74
  }
47
75
  if (e.type === 'response.output_text.delta' && typeof e.delta === 'string') {
48
76
  text += e.delta;
49
77
  onText?.(e.delta);
78
+ onEvent?.({ type: 'text_delta', delta: e.delta });
79
+ }
80
+ if (e.type === 'response.output_item.added' && isRecord(e.item)) {
81
+ const key = typeof e.item.call_id === 'string' ? e.item.call_id : callKey(e);
82
+ const at = calls.get(key) ?? { args: '' };
83
+ if (typeof e.item.name === 'string')
84
+ at.name = e.item.name;
85
+ if (typeof e.item.call_id === 'string')
86
+ at.callId = e.item.call_id;
87
+ calls.set(key, at);
50
88
  }
51
- if (e.type === 'response.completed') {
89
+ if (e.type === 'response.function_call_arguments.delta' && typeof e.delta === 'string') {
90
+ const at = calls.get(callKey(e)) ?? { args: '' };
91
+ at.args += e.delta;
92
+ calls.set(callKey(e), at);
93
+ onEvent?.({ type: 'function_call_delta', name: at.name, callId: at.callId, delta: e.delta });
94
+ }
95
+ if (e.type === 'response.output_item.done' && isRecord(e.item)) {
96
+ if (e.item.type === 'function_call' && typeof e.item.arguments !== 'string') {
97
+ const at = calls.get(callKey(e));
98
+ if (at && at.args)
99
+ e.item = { ...e.item, arguments: at.args };
100
+ }
101
+ land(e.item, e.output_index ?? 0);
102
+ }
103
+ if (e.type === 'response.completed' || e.type === 'response.incomplete') {
52
104
  done = true;
53
- if (Array.isArray(e.response?.output))
105
+ if (Array.isArray(e.response?.output)) {
106
+ for (const [i, item] of e.response.output.entries())
107
+ land(item, i);
54
108
  completed = e.response.output.flatMap((o) => o?.content ?? []).filter((c) => c?.type === 'output_text').map((c) => c.text ?? '').join('');
109
+ }
55
110
  }
56
111
  const failed = e.type === 'error' ? e : e.type === 'response.failed' ? e.response?.error : undefined;
57
112
  if (failed) {
@@ -61,45 +116,62 @@ export function sseReader(onText) {
61
116
  }
62
117
  };
63
118
  const drain = (final) => {
64
- const blocks = buffer.replace(/\r\n/g, '\n').split('\n\n');
65
- buffer = final ? '' : blocks.pop();
119
+ // A trailing CR may be a bare-CR line ending or half of a chunk-split CRLF: hold it until more arrives.
120
+ let tail = '';
121
+ if (!final && buffer.endsWith('\r')) {
122
+ tail = '\r';
123
+ buffer = buffer.slice(0, -1);
124
+ }
125
+ const blocks = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n\n');
126
+ buffer = (final ? '' : blocks.pop()) + tail;
66
127
  for (const b of blocks)
67
128
  event(b);
68
129
  };
69
- return {
70
- push(chunk) { buffer += chunk; drain(false); },
71
- end() {
130
+ const finish = () => {
131
+ if (!finished) {
72
132
  drain(true);
73
133
  if (!done)
74
134
  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
- },
135
+ const whole = text !== '' ? text : (completed ?? '');
136
+ if (whole === '' && output.length === 0)
137
+ throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
138
+ if (text === '' && whole !== '')
139
+ onText?.(whole);
140
+ finished = { text: whole, output };
141
+ }
142
+ return finished;
143
+ };
144
+ return {
145
+ push(chunk) { buffer += chunk; drain(false); },
146
+ end() { return finish().text; },
147
+ result() { return finish(); },
82
148
  };
83
149
  }
84
- /** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works. */
85
150
  export async function respond(o) {
151
+ const input = typeof o.input === 'string'
152
+ ? [{ role: 'user', content: [{ type: 'input_text', text: o.input }] }]
153
+ : o.input;
154
+ const { verbosity = 'low', format, ...textRest } = o.text ?? {};
155
+ const { effort = 'none', ...reasoningRest } = o.reasoning ?? {};
86
156
  const res = await (o.fetch ?? fetch)(`${o.base ?? 'https://chatgpt.com/backend-api'}/codex/responses`, {
87
157
  method: 'POST', signal: o.signal,
88
158
  headers: {
89
159
  'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${o.access}`,
90
- 'chatgpt-account-id': o.accountId, 'OpenAI-Beta': 'responses=experimental', originator: 'byokit',
160
+ 'chatgpt-account-id': o.accountId, 'OpenAI-Beta': 'responses=experimental', originator: o.originator ?? 'byokit',
91
161
  },
92
162
  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' },
163
+ model: o.model, store: false, stream: true, instructions: o.instructions, input,
164
+ ...(o.tools ? { tools: o.tools } : {}),
165
+ ...(o.tool_choice !== undefined ? { tool_choice: o.tool_choice } : {}),
166
+ text: { verbosity, ...(format ? { format } : {}), ...textRest },
167
+ reasoning: { effort, ...reasoningRest },
96
168
  }),
97
169
  });
98
170
  if (!res.ok) {
99
171
  const e = limitResponse(res.status, await res.text().catch(() => ''));
100
172
  throw new ResponseError(e.message, e.kind, e.until ?? 0);
101
173
  }
102
- const reader = sseReader(o.onText);
174
+ const reader = sseReader(o.onText, o.onEvent);
103
175
  const body = res.body;
104
176
  if (body?.getReader && typeof TextDecoder !== 'undefined') {
105
177
  const r = body.getReader();
@@ -110,5 +182,5 @@ export async function respond(o) {
110
182
  else {
111
183
  reader.push(await res.text()); // a fetch that can't stream: the whole answer at once
112
184
  }
113
- return reader.end();
185
+ return o.tools ? reader.result() : reader.end();
114
186
  }
@@ -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 text = `You said: ${json().input?.[0]?.content?.[0]?.text ?? ''}`;
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Sign in with the AI plan you already pay for, into your app's own store.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",