@byokit/accounts 0.5.0 → 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,7 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## 0.5.0 (2026-09-29)
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.
6
12
 
7
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.
8
14
 
@@ -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): {
package/dist/accounts.js CHANGED
@@ -236,7 +236,7 @@ export class Accounts {
236
236
  if (!access || c?.type !== 'oauth')
237
237
  throw new ResponseError(say('status.signedOut', { name: p.name }), 'signed_out');
238
238
  try {
239
- const base = { ...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
240
  return ask.tools ? await respond({ ...base, tools: ask.tools }) : await respond({ ...base, tools: undefined });
241
241
  }
242
242
  catch (e) {
@@ -147,7 +147,9 @@ export type ResponseStreamEvent = {
147
147
  };
148
148
  /** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text,
149
149
  * `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
150
- * An error event throws a ResponseError. */
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. */
151
153
  export declare function sseReader(onText?: (delta: string) => void, onEvent?: (event: ResponseStreamEvent) => void): {
152
154
  push(chunk: string): void;
153
155
  end(): string;
@@ -174,6 +176,8 @@ export type Ask = {
174
176
  /** Each tool call and output item as it lands. */
175
177
  onEvent?: (event: ResponseStreamEvent) => void;
176
178
  signal?: AbortSignal;
179
+ /** The app's own originator header value. Default: 'byokit'. */
180
+ originator?: string;
177
181
  };
178
182
  type Access = {
179
183
  access: string;
package/dist/responses.js CHANGED
@@ -39,7 +39,9 @@ const itemKey = (item, fallback) => typeof item.call_id === 'string' ? item.call
39
39
  export const isFunctionCall = (item) => isRecord(item) && item.type === 'function_call' && typeof item.name === 'string' && typeof item.arguments === 'string';
40
40
  /** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text,
41
41
  * `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
42
- * An error event throws a ResponseError. */
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. */
43
45
  export function sseReader(onText, onEvent) {
44
46
  let buffer = '', text = '', completed;
45
47
  let done = false, finished;
@@ -68,7 +70,7 @@ export function sseReader(onText, onEvent) {
68
70
  e = JSON.parse(data);
69
71
  }
70
72
  catch {
71
- return;
73
+ throw new ResponseError("ChatGPT's answer could not be read.", null);
72
74
  }
73
75
  if (e.type === 'response.output_text.delta' && typeof e.delta === 'string') {
74
76
  text += e.delta;
@@ -114,8 +116,14 @@ export function sseReader(onText, onEvent) {
114
116
  }
115
117
  };
116
118
  const drain = (final) => {
117
- const blocks = buffer.replace(/\r\n/g, '\n').split('\n\n');
118
- 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;
119
127
  for (const b of blocks)
120
128
  event(b);
121
129
  };
@@ -124,12 +132,11 @@ export function sseReader(onText, onEvent) {
124
132
  drain(true);
125
133
  if (!done)
126
134
  throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
127
- let whole = text;
128
- if (completed !== undefined && completed !== text) {
129
- if (completed.startsWith(text))
130
- onText?.(completed.slice(text.length));
131
- whole = completed;
132
- }
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);
133
140
  finished = { text: whole, output };
134
141
  }
135
142
  return finished;
@@ -150,7 +157,7 @@ export async function respond(o) {
150
157
  method: 'POST', signal: o.signal,
151
158
  headers: {
152
159
  'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${o.access}`,
153
- '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',
154
161
  },
155
162
  body: JSON.stringify({
156
163
  model: o.model, store: false, stream: true, instructions: o.instructions, input,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.5.0",
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",