@byokit/accounts 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.1 (2026-09-30)
6
+
7
+ - FIX: a cut-off answer is now reported as cut off: `respond` throws `IncompleteError` with its reason and partial output, and notifies `onEvent`, instead of returning it as finished.
8
+
5
9
  ## 0.7.0 (2026-09-30)
6
10
 
7
11
  - Add the portable `chatgptPlan` adapter for a host-validated official token-sharing session, checking
package/README.md CHANGED
@@ -119,7 +119,7 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
119
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
120
  | `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing, terms status, reason and source |
121
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 |
122
+ | `respond`, `ResponseError`, `IncompleteError`, `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
123
  | `classify`, `REST_MS` | An error's kind (limit, overload, plan without this use, lapsed sign-in, network) and default rest times |
124
124
  | `planOf`, `claims` | The ChatGPT plan and email behind a sign-in, from its own token |
125
125
  | `deviceStart`, `devicePoll`, `credentialOf`, `portableEngine`, `PORTABLE` | The device-code flow, the sign-in built from a token answer, and the engine under `portable` |
@@ -217,6 +217,14 @@ member's sign-in, refreshed first when due, and returns the whole text (`onText`
217
217
  returned completion is authoritative). A limit or a lapsed sign-in is acted on as `failed()` does, then thrown as a
218
218
  `ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
219
219
 
220
+ A cut-off answer always throws `IncompleteError` (a `ResponseError` with `kind: null`), with or without tools.
221
+ Its `reason` preserves the provider's `incomplete_details.reason`, including `max_output_tokens` and
222
+ `content_filter` (`unknown` when absent). Its `result` holds the partial `{ text, output }` for apps that want to
223
+ show it as unfinished. `onEvent` also receives `{ type: 'incomplete', reason }` before rejection; `onText` may
224
+ already have shown partial words. This covers `response.incomplete` events and `status: 'incomplete'` envelopes,
225
+ whether fetch streams SSE, buffers it, or returns JSON. The account stays signed in and is not put to rest.
226
+ Successful return values are unchanged.
227
+
220
228
  The whole question passes through: `input` takes the turns so far (messages, with `input_image` where the person
221
229
  attached a picture), `tools` and `tool_choice` take the app's own function tools and built-ins (including
222
230
  `image_generation`), `reasoning.effort` how hard the model thinks, and `text` how long the answer is with the shape it
@@ -2,7 +2,7 @@ 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, 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';
5
+ export { IncompleteError, 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';
8
8
  export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
package/dist/portable.js CHANGED
@@ -4,7 +4,7 @@ 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, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
7
+ export { IncompleteError, 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";
10
10
  export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
@@ -6,6 +6,12 @@ export declare class ResponseError extends Error {
6
6
  until: number;
7
7
  constructor(message: string, kind: Kind | null, until?: number);
8
8
  }
9
+ /** An answer the provider cut off. Partial output is available, but never returned as a successful answer. */
10
+ export declare class IncompleteError extends ResponseError {
11
+ reason: string;
12
+ result: ResponseResult;
13
+ constructor(reason: string, result: ResponseResult);
14
+ }
9
15
  /** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
10
16
  export declare function limitResponse(status: number, body: string, now?: number): {
11
17
  kind: Kind | null;
@@ -129,6 +135,9 @@ export type ResponseResult = {
129
135
  };
130
136
  /** What streams besides the words: each text piece, each tool call as it builds and lands, and each output item. */
131
137
  export type ResponseStreamEvent = {
138
+ type: 'incomplete';
139
+ reason: string;
140
+ } | {
132
141
  type: 'text_delta';
133
142
  delta: string;
134
143
  } | {
@@ -149,7 +158,8 @@ export type ResponseStreamEvent = {
149
158
  * `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
150
159
  * Events split on any blank line (LF, CRLF or bare CR). A data line that is not JSON throws a ResponseError;
151
160
  * 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. */
161
+ * only fills in when no deltas arrived. An error event throws a ResponseError. An incomplete answer
162
+ * emits an incomplete event and throws IncompleteError with its reason and partial result. */
153
163
  export declare function sseReader(onText?: (delta: string) => void, onEvent?: (event: ResponseStreamEvent) => void): {
154
164
  push(chunk: string): void;
155
165
  end(): string;
@@ -173,7 +183,7 @@ export type Ask = {
173
183
  text?: ResponseText;
174
184
  /** Each piece of the answer as it streams. */
175
185
  onText?: (delta: string) => void;
176
- /** Each tool call and output item as it lands. */
186
+ /** Each text piece, tool call, output item, and incomplete answer notification. */
177
187
  onEvent?: (event: ResponseStreamEvent) => void;
178
188
  signal?: AbortSignal;
179
189
  /** The app's own originator header value. Default: 'byokit'. */
@@ -187,7 +197,8 @@ type Access = {
187
197
  fetch?: typeof fetch;
188
198
  };
189
199
  /** 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. */
200
+ * Without `tools` the answer is the plain text, as before; with `tools` it is the text with every output item.
201
+ * Incomplete answers always throw IncompleteError and notify onEvent, including with tools. */
191
202
  export declare function respond(o: Ask & Access & {
192
203
  tools?: undefined;
193
204
  }): Promise<string>;
package/dist/responses.js CHANGED
@@ -16,6 +16,17 @@ export class ResponseError extends Error {
16
16
  until;
17
17
  constructor(message, kind, until = 0) { super(message); this.kind = kind; this.until = until; }
18
18
  }
19
+ /** An answer the provider cut off. Partial output is available, but never returned as a successful answer. */
20
+ export class IncompleteError extends ResponseError {
21
+ reason;
22
+ result;
23
+ constructor(reason, result) {
24
+ super('ChatGPT cut off its answer before it was complete.', null);
25
+ this.name = 'IncompleteError';
26
+ this.reason = reason;
27
+ this.result = result;
28
+ }
29
+ }
19
30
  /** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
20
31
  export function limitResponse(status, body, now = Date.now()) {
21
32
  let err = {};
@@ -41,10 +52,12 @@ export const isFunctionCall = (item) => isRecord(item) && item.type === 'functio
41
52
  * `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
42
53
  * Events split on any blank line (LF, CRLF or bare CR). A data line that is not JSON throws a ResponseError;
43
54
  * 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. */
55
+ * only fills in when no deltas arrived. An error event throws a ResponseError. An incomplete answer
56
+ * emits an incomplete event and throws IncompleteError with its reason and partial result. */
45
57
  export function sseReader(onText, onEvent) {
46
58
  let buffer = '', text = '', completed;
47
59
  let done = false, finished;
60
+ let incomplete;
48
61
  const output = [];
49
62
  const emitted = new Set();
50
63
  const calls = new Map();
@@ -100,7 +113,7 @@ export function sseReader(onText, onEvent) {
100
113
  }
101
114
  land(e.item, e.output_index ?? 0);
102
115
  }
103
- if (e.type === 'response.completed' || e.type === 'response.incomplete') {
116
+ if (e.type === 'response.completed' || e.type === 'response.incomplete' || e.response?.status === 'incomplete') {
104
117
  done = true;
105
118
  if (Array.isArray(e.response?.output)) {
106
119
  for (const [i, item] of e.response.output.entries())
@@ -108,6 +121,11 @@ export function sseReader(onText, onEvent) {
108
121
  completed = e.response.output.flatMap((o) => o?.content ?? []).filter((c) => c?.type === 'output_text').map((c) => c.text ?? '').join('');
109
122
  }
110
123
  }
124
+ if ((e.type === 'response.incomplete' || e.response?.status === 'incomplete') && incomplete === undefined) {
125
+ const reason = typeof e.response?.incomplete_details?.reason === 'string' ? e.response.incomplete_details.reason : 'unknown';
126
+ incomplete = reason;
127
+ onEvent?.({ type: 'incomplete', reason });
128
+ }
111
129
  const failed = e.type === 'error' ? e : e.type === 'response.failed' ? e.response?.error : undefined;
112
130
  if (failed) {
113
131
  const message = String(failed.message ?? 'Request failed');
@@ -133,12 +151,14 @@ export function sseReader(onText, onEvent) {
133
151
  if (!done)
134
152
  throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
135
153
  const whole = text !== '' ? text : (completed ?? '');
136
- if (whole === '' && output.length === 0)
154
+ if (incomplete === undefined && whole === '' && output.length === 0)
137
155
  throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
138
156
  if (text === '' && whole !== '')
139
157
  onText?.(whole);
140
158
  finished = { text: whole, output };
141
159
  }
160
+ if (incomplete !== undefined)
161
+ throw new IncompleteError(incomplete, finished);
142
162
  return finished;
143
163
  };
144
164
  return {
@@ -173,7 +193,18 @@ export async function respond(o) {
173
193
  }
174
194
  const reader = sseReader(o.onText, o.onEvent);
175
195
  const body = res.body;
176
- if (body?.getReader && typeof TextDecoder !== 'undefined') {
196
+ if (res.headers?.get('content-type')?.includes('application/json')) {
197
+ let response;
198
+ try {
199
+ response = JSON.parse(await res.text());
200
+ }
201
+ catch {
202
+ throw new ResponseError("ChatGPT's answer could not be read.", null);
203
+ }
204
+ const type = isRecord(response) ? `response.${String(response.status)}` : '';
205
+ reader.push(`data: ${JSON.stringify({ type, response })}\n\n`);
206
+ }
207
+ else if (body?.getReader && typeof TextDecoder !== 'undefined') {
177
208
  const r = body.getReader();
178
209
  const decoder = new TextDecoder();
179
210
  for (let c = await r.read(); !c.done; c = await r.read())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",