@rei-standard/amsg-shared 0.4.0-next.9 → 0.4.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/README.md CHANGED
@@ -26,10 +26,10 @@ The two are intentionally independent: any `messageType` can carry any
26
26
  `messageKind`.
27
27
 
28
28
  There is also `source: 'instant' | 'scheduled'` — the **routing
29
- origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
30
- `amsg-server` output). `messageType: 'instant'` always pairs with
31
- `source: 'instant'`; the other three `messageType`s always pair with
32
- `source: 'scheduled'`.
29
+ origin**: `messageType: 'instant'` (both `amsg-instant` and
30
+ `amsg-server`'s in-server instant path) always pairs with
31
+ `source: 'instant'`; the other three scheduled `messageType`s always
32
+ pair with `source: 'scheduled'`.
33
33
 
34
34
  ---
35
35
 
@@ -45,6 +45,7 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
45
45
  | `timestamp` | `string` (ISO 8601) | Producer-side wall clock. |
46
46
  | `messageSubtype` | `string?` | Caller's business namespace. Defaults to `'chat'` at producers. |
47
47
  | `metadata` | `object?` | **Caller passthrough.** Packages MUST NOT write here. |
48
+ | `notification` | `NotificationDirective?` | Optional producer-side notification display hint (see below). |
48
49
  | `taskId` | `number \| string \| null?` | Scheduled task row id. |
49
50
  | `taskUuid` | `string \| null?` | Scheduled task uuid (the id the scheduling side chose). |
50
51
  | `recurrenceType` | `'none' \| 'daily' \| 'weekly'?` | Whether that task fires again. |
@@ -64,7 +65,7 @@ omit all four.
64
65
 
65
66
  ## Notification directive
66
67
 
67
- `ContentPush` and `ToolRequestPush` can carry an optional
68
+ Every push variant (`AmsgPushCommon`) can carry an optional
68
69
  `notification` object. It is a producer-side hint consumed by
69
70
  `@rei-standard/amsg-sw` before rendering a system notification.
70
71
 
@@ -187,12 +188,14 @@ instead and may carry a `toolCalls` array directly — see
187
188
 
188
189
  ### `ErrorPush` — producer-level error
189
190
 
190
- | Field | Type | Notes |
191
- |---------------|-----------|------------------------------------------------------------------------|
192
- | `messageKind` | `'error'` | Discriminator. |
193
- | `code` | `string` | Stable producer-defined code, e.g. `HOOK_THREW`, `LOOP_EXCEEDED`. |
194
- | `message` | `string` | Human-readable description. |
195
- | `iteration` | `number?` | Agentic-loop iteration when relevant. |
191
+ | Field | Type | Notes |
192
+ |----------------|-----------|------------------------------------------------------------------------|
193
+ | `messageKind` | `'error'` | Discriminator. |
194
+ | `code` | `string` | Stable producer-defined code, e.g. `HOOK_THREW`, `LOOP_EXCEEDED`. |
195
+ | `message` | `string` | Human-readable description. |
196
+ | `iteration` | `number?` | Agentic-loop iteration when relevant. |
197
+ | `llmStatus` | `number?` | Upstream LLM HTTP status code when the error came from an LLM call. |
198
+ | `providerCode` | `string?` | Upstream provider error code when present in the LLM error body. |
196
199
 
197
200
  Replaces the legacy 0.7.0 `{ type: 'error', code: '...' }` envelope.
198
201
  The legacy `type` field is **gone** — do not look for it on
@@ -206,6 +209,7 @@ The legacy `type` field is **gone** — do not look for it on
206
209
  | `resultKind` | `string` | 宿主给这类结果起的名字(`'fire-pack'`、`'ledger-entry'`……),客户端按它分流。 |
207
210
  | `title` | `string?` | 通知标题的兜底(`notification.title` 优先)。 |
208
211
  | `body` | `string?` | 通知正文的兜底(`notification.body` 优先)。 |
212
+ | `data` | `object?` | 可选结构化数据字段(任意其它顶层自定义字段也会原样保留)。 |
209
213
 
210
214
  不是聊天内容,而是「这次跑完产出了点什么,客户端拿去自己消化」:整理好的一份数据、一条账目、后台生成的产物。形状由宿主定,`buildResultPush` 是唯一**保留自己不认识的字段**的 builder——白名单式的复制会把内容删掉一半。
211
215
 
@@ -362,19 +366,20 @@ the full contract.
362
366
 
363
367
  `toUint8` · `concatBytes` · `utf8` · `utf8Decode` · `bytesToBase64` ·
364
368
  `bytesToBase64Url` · `base64UrlToBytes` · `jsonToBase64Url` ·
365
- `bytesToHex` · `hexToBytes` · `randomBytes` · `hmacSha256` ·
369
+ `bytesToHex` · `hexToBytes` · `randomBytes` · `randomUUID` · `hmacSha256` ·
366
370
  `timingSafeEqualBytes` — WebCrypto-friendly byte/encoding helpers for
367
- base64url, hex, HMAC, and constant-time comparison.
371
+ base64url, hex, UUID v4 generation, HMAC, and constant-time comparison.
368
372
 
369
373
  ### LLM call
370
374
 
371
375
  | Export | What it is |
372
376
  |---|---|
373
377
  | `callLlm(...)` | Call an OpenAI-compatible chat-completions endpoint with timeout / abort handling (default 300 000 ms, injectable `fetch`). |
374
- | `buildLlmRequestBody(...)` | Build the request body for prompt mode or `messages` mode. |
378
+ | `buildLlmRequestBody(...)` | Build the request body for prompt mode or `messages` mode (with optional `llmExtraBody`). |
375
379
  | `normalizeAiApiUrl(apiUrl)` | Normalize a base URL to its chat-completions endpoint without doubling `/v1`; unrecognized paths pass through unchanged. |
376
380
  | `validateLlmMessagesShape(...)` | Validate a `messages` array, including assistant `tool_calls` and `role: 'tool'` entries. |
377
381
  | `LLM_MESSAGES_ERROR` | Stable error codes emitted by that validation. |
382
+ | `redactCredentials(text)` | Redact `Bearer ...`, `sk-...`, and long opaque credential tokens from error strings while keeping model names intact. |
378
383
 
379
384
  ### Web Push
380
385
 
@@ -388,8 +393,9 @@ base64url, hex, HMAC, and constant-time comparison.
388
393
 
389
394
  | Export | What it is |
390
395
  |---|---|
391
- | `MULTIPART_MESSAGE_KIND` / `MULTIPART_ENCODING` / `MULTIPART_VERSION` | The generic multipart chunk envelope (`'_multipart'`) produced by `amsg-instant` and reassembled by `amsg-sw`. |
392
- | `DEFAULT_MULTIPART_TTL_MS` / `DEFAULT_MULTIPART_MAX_CHUNKS` / `DEFAULT_MULTIPART_MAX_TOTAL_BYTES` | Multipart reassembly budget defaults. |
396
+ | `MULTIPART_MESSAGE_KIND` / `MULTIPART_ENCODING` / `MULTIPART_VERSION` / `MULTIPART_FAILURE_REASON` | The generic multipart chunk envelope (`'_multipart'`) produced by `amsg-instant` / `amsg-server` and reassembled by `amsg-sw`, plus the failure reason enum. |
397
+ | `DEFAULT_MULTIPART_CHUNK_BYTES` / `DEFAULT_MULTIPART_TTL_MS` / `DEFAULT_MULTIPART_MAX_CHUNKS` / `DEFAULT_MULTIPART_MAX_TOTAL_BYTES` | Multipart chunking and reassembly budget defaults. |
398
+ | `buildMultipartPushPayloads(payload, options?)` | Slice a JSON-safe push payload into `_multipart` chunk envelopes. |
393
399
  | `REI_AMSG_POSTMESSAGE_TYPE` / `REI_SW_EVENT` / `REI_SW_MESSAGE_TYPE` / `REI_AMSG_DELIVER_MESSAGE_TYPE` | The window ⇄ Service Worker `postMessage` protocol strings — import these instead of hard-coding the literals. |
394
400
 
395
401
  ### Agentic-loop contract
@@ -398,9 +404,9 @@ base64url, hex, HMAC, and constant-time comparison.
398
404
  |---|---|
399
405
  | `assertValidDecision(decision, options?)` | Runtime-validate an `onLLMOutput` hook decision (`finish` / `tool-request` / `continue` / `skip-push`); `{ inlineToolCalls: true }` enables the amsg-server flavor. |
400
406
  | `extractToolCallsFromDecision(...)` | Pull tool calls out of either decision flavor (inline `toolCalls` or tool-request `pushPayloads`). |
401
- | `buildSessionContext(...)` | Build the frozen, credential-free context object handed to agentic hooks. |
407
+ | `buildSessionContext(...)` | Build the frozen, credential-free context object handed to agentic hooks (includes `usage` and optional `scratch`). |
402
408
  | `extractAssistantMessage(...)` | Safely read `choices[0].message` off an LLM response (never throws). |
403
- | `readReasoningContent(...)` | Read `reasoning_content` off an assistant message; empty string means "none". |
409
+ | `readReasoningContent(llmResponse)` | Read `reasoning_content` off an LLM response (or fall back to `<think>` / `<thinking>` / `<thought>` tags in `message.content`); returns `string \| null`. |
404
410
  | `stripReasoningTags(...)` | Strip reasoning that leaked into `message.content` so it doesn't ship inside the `ContentPush` burst. |
405
411
  | `chunkReasoningByUtf8Bytes(text, maxBytes)` | Split reasoning text on safe UTF-8 edges for payload-limited transports. |
406
412
 
@@ -427,8 +433,9 @@ avatar-URL soft-strip rule shared by client / instant / server
427
433
  multi-part bursts — never as a redundant `1 / 1`.
428
434
  4. **`metadata` is caller-owned.** Packages must add protocol-level
429
435
  data as top-level fields, never inside `metadata`.
430
- 5. **`source` is the routing origin, not the dispatch type.**
431
- `'instant'` ⇄ `amsg-instant`; `'scheduled'` ⇄ `amsg-server`.
436
+ 5. **`source` is the routing origin.**
437
+ `messageType: 'instant'` pairs with `source: 'instant'`; scheduled
438
+ `messageType`s (`fixed` / `prompted` / `auto`) pair with `source: 'scheduled'`.
432
439
 
433
440
  See [§6 of `standards/active-messaging-api.md`](../../../standards/active-messaging-api.md)
434
441
  for the wire-level contract.
package/dist/index.cjs CHANGED
@@ -253,7 +253,7 @@ async function callLlm(payload, options = {}) {
253
253
  "Authorization": `Bearer ${payload.apiKey}`
254
254
  },
255
255
  body: JSON.stringify(requestBody),
256
- signal: AbortSignal.timeout(timeoutMs)
256
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs)
257
257
  });
258
258
  if (!aiResponse.ok) {
259
259
  const detail = await readUpstreamErrorDetail(aiResponse);
@@ -270,8 +270,24 @@ async function callLlm(payload, options = {}) {
270
270
  detail
271
271
  );
272
272
  }
273
- const aiData = await aiResponse.json();
274
- const rawContent = aiData?.choices?.[0]?.message?.content;
273
+ const okStatus = Number.isInteger(aiResponse.status) ? aiResponse.status : 200;
274
+ const body = await readCompletionBody(aiResponse);
275
+ if (!body.parsed) {
276
+ throw buildUpstreamError(
277
+ `AI API error: HTTP ${okStatus} but body is not valid JSON. Request URL: ${normalizedApiUrl}`,
278
+ okStatus,
279
+ describeUnparsableBody(body.raw)
280
+ );
281
+ }
282
+ const aiData = body.data;
283
+ if (!isChatCompletionShape(aiData)) {
284
+ throw buildUpstreamError(
285
+ `AI API error: HTTP ${okStatus} but body is not a chat completion (no choices). Request URL: ${normalizedApiUrl}`,
286
+ okStatus,
287
+ describeNonCompletionBody(aiData)
288
+ );
289
+ }
290
+ const rawContent = aiData.choices[0]?.message?.content;
275
291
  if (requireContent && (typeof rawContent !== "string" || !rawContent.trim())) {
276
292
  throw new Error("AI API error: response missing choices[0].message.content");
277
293
  }
@@ -365,6 +381,9 @@ async function readUpstreamErrorDetail(response) {
365
381
  }
366
382
  return { message: clampDetail(raw), code: "" };
367
383
  }
384
+ return extractErrorEnvelopeDetail(body, { fallbackMessage: raw });
385
+ }
386
+ function extractErrorEnvelopeDetail(body, { fallbackMessage = "", keepNumericCode = false } = {}) {
368
387
  const envelope = body && typeof body === "object" ? body : {};
369
388
  const inner = envelope.error && typeof envelope.error === "object" ? envelope.error : {};
370
389
  const message = firstNonEmptyString(
@@ -374,9 +393,11 @@ async function readUpstreamErrorDetail(response) {
374
393
  // `{ error: "unauthorized" }`
375
394
  envelope.message,
376
395
  // 一批中转把 message 放最外层
396
+ envelope.msg,
397
+ // 国内中转的 `{ code, msg }`
377
398
  envelope.detail
378
399
  // FastAPI 风格的自建中转
379
- ) || raw;
400
+ ) || fallbackMessage;
380
401
  const code = firstNonEmptyString(
381
402
  inner.code,
382
403
  // OpenAI:invalid_api_key / insufficient_quota / context_length_exceeded
@@ -385,9 +406,61 @@ async function readUpstreamErrorDetail(response) {
385
406
  inner.type,
386
407
  // Anthropic:invalid_request_error / overloaded_error
387
408
  envelope.code
388
- );
409
+ ) || (keepNumericCode ? firstIntegerString(inner.code, envelope.code) : "");
389
410
  return { message: clampDetail(message), code: clampCode(code) };
390
411
  }
412
+ var EMPTY_BODY_NOTE = "response body is empty";
413
+ var NON_COMPLETION_KEYS_MAX = 10;
414
+ async function readCompletionBody(response) {
415
+ if (typeof response.text === "function") {
416
+ const raw = await response.text();
417
+ try {
418
+ return { parsed: true, data: JSON.parse(raw) };
419
+ } catch {
420
+ return { parsed: false, raw: typeof raw === "string" ? raw : null };
421
+ }
422
+ }
423
+ try {
424
+ return { parsed: true, data: await response.json() };
425
+ } catch (error) {
426
+ if (error && /** @type {any} */
427
+ error.name === "SyntaxError") return { parsed: false, raw: null };
428
+ throw error;
429
+ }
430
+ }
431
+ function isChatCompletionShape(data) {
432
+ return !!data && typeof data === "object" && Array.isArray(
433
+ /** @type {any} */
434
+ data.choices
435
+ ) && /** @type {any} */
436
+ data.choices.length > 0;
437
+ }
438
+ function describeUnparsableBody(raw) {
439
+ if (raw === null) return { message: "", code: "" };
440
+ const trimmed = raw.trim();
441
+ if (!trimmed) return { message: EMPTY_BODY_NOTE, code: "" };
442
+ if (looksLikeSseStream(trimmed)) return { message: SSE_BODY_NOTE, code: "" };
443
+ return { message: clampDetail(raw), code: "" };
444
+ }
445
+ function looksLikeSseStream(trimmed) {
446
+ return trimmed.startsWith(":") || SSE_FIELD_LINE.test(trimmed);
447
+ }
448
+ var SSE_FIELD_LINE = /^(?:data|event|id|retry):/im;
449
+ var SSE_BODY_NOTE = "response body looks like an SSE stream (the endpoint ignored stream: false)";
450
+ function describeNonCompletionBody(data) {
451
+ const detail = extractErrorEnvelopeDetail(data, { keepNumericCode: true });
452
+ if (detail.message || detail.code) return detail;
453
+ let shape;
454
+ if (Array.isArray(data)) {
455
+ shape = "response body is a JSON array";
456
+ } else if (data && typeof data === "object") {
457
+ const keys = Object.keys(data);
458
+ shape = keys.length === 0 ? "response body is an empty object" : `top-level keys: ${keys.slice(0, NON_COMPLETION_KEYS_MAX).join(", ")}` + (keys.length > NON_COMPLETION_KEYS_MAX ? ", \u2026" : "");
459
+ } else {
460
+ shape = `response body is ${data === null ? "null" : `a JSON ${typeof data}`}`;
461
+ }
462
+ return { message: clampDetail(shape), code: "" };
463
+ }
391
464
  async function readBoundedBody(response) {
392
465
  const body = response && response.body;
393
466
  if (!body || typeof body.getReader !== "function") {
@@ -512,6 +585,12 @@ function firstNonEmptyString(...values) {
512
585
  }
513
586
  return "";
514
587
  }
588
+ function firstIntegerString(...values) {
589
+ for (const value of values) {
590
+ if (Number.isInteger(value)) return String(value);
591
+ }
592
+ return "";
593
+ }
515
594
 
516
595
  // src/webpush.js
517
596
  var KEY_INFO_PREFIX = utf8("WebPush: info\0");
package/dist/index.mjs CHANGED
@@ -168,7 +168,7 @@ async function callLlm(payload, options = {}) {
168
168
  "Authorization": `Bearer ${payload.apiKey}`
169
169
  },
170
170
  body: JSON.stringify(requestBody),
171
- signal: AbortSignal.timeout(timeoutMs)
171
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs)
172
172
  });
173
173
  if (!aiResponse.ok) {
174
174
  const detail = await readUpstreamErrorDetail(aiResponse);
@@ -185,8 +185,24 @@ async function callLlm(payload, options = {}) {
185
185
  detail
186
186
  );
187
187
  }
188
- const aiData = await aiResponse.json();
189
- const rawContent = aiData?.choices?.[0]?.message?.content;
188
+ const okStatus = Number.isInteger(aiResponse.status) ? aiResponse.status : 200;
189
+ const body = await readCompletionBody(aiResponse);
190
+ if (!body.parsed) {
191
+ throw buildUpstreamError(
192
+ `AI API error: HTTP ${okStatus} but body is not valid JSON. Request URL: ${normalizedApiUrl}`,
193
+ okStatus,
194
+ describeUnparsableBody(body.raw)
195
+ );
196
+ }
197
+ const aiData = body.data;
198
+ if (!isChatCompletionShape(aiData)) {
199
+ throw buildUpstreamError(
200
+ `AI API error: HTTP ${okStatus} but body is not a chat completion (no choices). Request URL: ${normalizedApiUrl}`,
201
+ okStatus,
202
+ describeNonCompletionBody(aiData)
203
+ );
204
+ }
205
+ const rawContent = aiData.choices[0]?.message?.content;
190
206
  if (requireContent && (typeof rawContent !== "string" || !rawContent.trim())) {
191
207
  throw new Error("AI API error: response missing choices[0].message.content");
192
208
  }
@@ -280,6 +296,9 @@ async function readUpstreamErrorDetail(response) {
280
296
  }
281
297
  return { message: clampDetail(raw), code: "" };
282
298
  }
299
+ return extractErrorEnvelopeDetail(body, { fallbackMessage: raw });
300
+ }
301
+ function extractErrorEnvelopeDetail(body, { fallbackMessage = "", keepNumericCode = false } = {}) {
283
302
  const envelope = body && typeof body === "object" ? body : {};
284
303
  const inner = envelope.error && typeof envelope.error === "object" ? envelope.error : {};
285
304
  const message = firstNonEmptyString(
@@ -289,9 +308,11 @@ async function readUpstreamErrorDetail(response) {
289
308
  // `{ error: "unauthorized" }`
290
309
  envelope.message,
291
310
  // 一批中转把 message 放最外层
311
+ envelope.msg,
312
+ // 国内中转的 `{ code, msg }`
292
313
  envelope.detail
293
314
  // FastAPI 风格的自建中转
294
- ) || raw;
315
+ ) || fallbackMessage;
295
316
  const code = firstNonEmptyString(
296
317
  inner.code,
297
318
  // OpenAI:invalid_api_key / insufficient_quota / context_length_exceeded
@@ -300,9 +321,61 @@ async function readUpstreamErrorDetail(response) {
300
321
  inner.type,
301
322
  // Anthropic:invalid_request_error / overloaded_error
302
323
  envelope.code
303
- );
324
+ ) || (keepNumericCode ? firstIntegerString(inner.code, envelope.code) : "");
304
325
  return { message: clampDetail(message), code: clampCode(code) };
305
326
  }
327
+ var EMPTY_BODY_NOTE = "response body is empty";
328
+ var NON_COMPLETION_KEYS_MAX = 10;
329
+ async function readCompletionBody(response) {
330
+ if (typeof response.text === "function") {
331
+ const raw = await response.text();
332
+ try {
333
+ return { parsed: true, data: JSON.parse(raw) };
334
+ } catch {
335
+ return { parsed: false, raw: typeof raw === "string" ? raw : null };
336
+ }
337
+ }
338
+ try {
339
+ return { parsed: true, data: await response.json() };
340
+ } catch (error) {
341
+ if (error && /** @type {any} */
342
+ error.name === "SyntaxError") return { parsed: false, raw: null };
343
+ throw error;
344
+ }
345
+ }
346
+ function isChatCompletionShape(data) {
347
+ return !!data && typeof data === "object" && Array.isArray(
348
+ /** @type {any} */
349
+ data.choices
350
+ ) && /** @type {any} */
351
+ data.choices.length > 0;
352
+ }
353
+ function describeUnparsableBody(raw) {
354
+ if (raw === null) return { message: "", code: "" };
355
+ const trimmed = raw.trim();
356
+ if (!trimmed) return { message: EMPTY_BODY_NOTE, code: "" };
357
+ if (looksLikeSseStream(trimmed)) return { message: SSE_BODY_NOTE, code: "" };
358
+ return { message: clampDetail(raw), code: "" };
359
+ }
360
+ function looksLikeSseStream(trimmed) {
361
+ return trimmed.startsWith(":") || SSE_FIELD_LINE.test(trimmed);
362
+ }
363
+ var SSE_FIELD_LINE = /^(?:data|event|id|retry):/im;
364
+ var SSE_BODY_NOTE = "response body looks like an SSE stream (the endpoint ignored stream: false)";
365
+ function describeNonCompletionBody(data) {
366
+ const detail = extractErrorEnvelopeDetail(data, { keepNumericCode: true });
367
+ if (detail.message || detail.code) return detail;
368
+ let shape;
369
+ if (Array.isArray(data)) {
370
+ shape = "response body is a JSON array";
371
+ } else if (data && typeof data === "object") {
372
+ const keys = Object.keys(data);
373
+ shape = keys.length === 0 ? "response body is an empty object" : `top-level keys: ${keys.slice(0, NON_COMPLETION_KEYS_MAX).join(", ")}` + (keys.length > NON_COMPLETION_KEYS_MAX ? ", \u2026" : "");
374
+ } else {
375
+ shape = `response body is ${data === null ? "null" : `a JSON ${typeof data}`}`;
376
+ }
377
+ return { message: clampDetail(shape), code: "" };
378
+ }
306
379
  async function readBoundedBody(response) {
307
380
  const body = response && response.body;
308
381
  if (!body || typeof body.getReader !== "function") {
@@ -427,6 +500,12 @@ function firstNonEmptyString(...values) {
427
500
  }
428
501
  return "";
429
502
  }
503
+ function firstIntegerString(...values) {
504
+ for (const value of values) {
505
+ if (Number.isInteger(value)) return String(value);
506
+ }
507
+ return "";
508
+ }
430
509
 
431
510
  // src/webpush.js
432
511
  var KEY_INFO_PREFIX = utf8("WebPush: info\0");
@@ -10,6 +10,7 @@
10
10
  * @param {{
11
11
  * requireContent?: boolean,
12
12
  * timeoutMs?: number,
13
+ * signal?: AbortSignal,
13
14
  * fetch?: typeof globalThis.fetch,
14
15
  * stream?: boolean,
15
16
  * forwardTools?: boolean,
@@ -17,21 +18,32 @@
17
18
  * requireContent defaults to true (legacy single-shot behavior:
18
19
  * throw when the response carries no content). Tool rounds legitimately
19
20
  * return no content (pure tool_calls), so agentic loops pass
20
- * `{ requireContent: false }`.
21
+ * `{ requireContent: false }`. It only decides whether an empty
22
+ * `content` counts as a failure; a 2xx body that is not a chat
23
+ * completion at all throws either way (see @throws).
21
24
  * timeoutMs defaults to 300000 (the legacy per-call ceiling).
22
25
  * fetch defaults to `globalThis.fetch` (resolved at call time so test
23
26
  * stubs on the global still take effect).
24
27
  * stream / forwardTools are forwarded to {@link buildLlmRequestBody}.
25
28
  * @returns {Promise<{ response: unknown, content: string }>}
26
- * @throws {Error} 上游回非 2xx 时抛,错误上带机读标注(见
29
+ * @throws {Error} 上游答复了、但没给出能用的结果时抛,错误上带机读标注(见
27
30
  * {@link buildUpstreamError}):`code` = `'LLM_CALL_FAILED'`、`llmStatus` =
28
31
  * 上游的 HTTP 状态码、`providerCode` = provider 自己的错误码(拿得到才有)。
29
- * 这三个字段只在上游确实答复了的时候出现——网络直接炸、超时、响应体不是
30
- * 合法 JSON 时不会有,接入方据此也能分清「上游拒了」和「根本没连上」。
32
+ * 两种情况:
33
+ * - 上游回非 2xx;
34
+ * - 上游回 2xx,但响应体解析不成 JSON,或者没有非空的 `choices` 数组(中转站
35
+ * 把报错塞进 200 响应体就是这样)。这时 `llmStatus` 就是那个 2xx,而且不管
36
+ * requireContent 取什么都抛。
37
+ * 这三个字段只在上游确实答复了的时候出现——网络直接炸、超时的时候不会有,接入
38
+ * 方据此也能分清「上游拒了」和「根本没连上」。
39
+ * `choices` 在、只是 content 为空(纯 tool_calls、被内容审核拦下)是合法的空
40
+ * 生成,requireContent 为 false 时照常返回;为 true 时抛的那条错误不带上面这些
41
+ * 标注。
31
42
  */
32
43
  export function callLlm(payload: any, options?: {
33
44
  requireContent?: boolean;
34
45
  timeoutMs?: number;
46
+ signal?: AbortSignal;
35
47
  fetch?: typeof globalThis.fetch;
36
48
  stream?: boolean;
37
49
  forwardTools?: boolean;
@@ -10,6 +10,7 @@
10
10
  * @param {{
11
11
  * requireContent?: boolean,
12
12
  * timeoutMs?: number,
13
+ * signal?: AbortSignal,
13
14
  * fetch?: typeof globalThis.fetch,
14
15
  * stream?: boolean,
15
16
  * forwardTools?: boolean,
@@ -17,21 +18,32 @@
17
18
  * requireContent defaults to true (legacy single-shot behavior:
18
19
  * throw when the response carries no content). Tool rounds legitimately
19
20
  * return no content (pure tool_calls), so agentic loops pass
20
- * `{ requireContent: false }`.
21
+ * `{ requireContent: false }`. It only decides whether an empty
22
+ * `content` counts as a failure; a 2xx body that is not a chat
23
+ * completion at all throws either way (see @throws).
21
24
  * timeoutMs defaults to 300000 (the legacy per-call ceiling).
22
25
  * fetch defaults to `globalThis.fetch` (resolved at call time so test
23
26
  * stubs on the global still take effect).
24
27
  * stream / forwardTools are forwarded to {@link buildLlmRequestBody}.
25
28
  * @returns {Promise<{ response: unknown, content: string }>}
26
- * @throws {Error} 上游回非 2xx 时抛,错误上带机读标注(见
29
+ * @throws {Error} 上游答复了、但没给出能用的结果时抛,错误上带机读标注(见
27
30
  * {@link buildUpstreamError}):`code` = `'LLM_CALL_FAILED'`、`llmStatus` =
28
31
  * 上游的 HTTP 状态码、`providerCode` = provider 自己的错误码(拿得到才有)。
29
- * 这三个字段只在上游确实答复了的时候出现——网络直接炸、超时、响应体不是
30
- * 合法 JSON 时不会有,接入方据此也能分清「上游拒了」和「根本没连上」。
32
+ * 两种情况:
33
+ * - 上游回非 2xx;
34
+ * - 上游回 2xx,但响应体解析不成 JSON,或者没有非空的 `choices` 数组(中转站
35
+ * 把报错塞进 200 响应体就是这样)。这时 `llmStatus` 就是那个 2xx,而且不管
36
+ * requireContent 取什么都抛。
37
+ * 这三个字段只在上游确实答复了的时候出现——网络直接炸、超时的时候不会有,接入
38
+ * 方据此也能分清「上游拒了」和「根本没连上」。
39
+ * `choices` 在、只是 content 为空(纯 tool_calls、被内容审核拦下)是合法的空
40
+ * 生成,requireContent 为 false 时照常返回;为 true 时抛的那条错误不带上面这些
41
+ * 标注。
31
42
  */
32
43
  export function callLlm(payload: any, options?: {
33
44
  requireContent?: boolean;
34
45
  timeoutMs?: number;
46
+ signal?: AbortSignal;
35
47
  fetch?: typeof globalThis.fetch;
36
48
  stream?: boolean;
37
49
  forwardTools?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-shared",
3
- "version": "0.4.0-next.9",
3
+ "version": "0.4.0",
4
4
  "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)",
5
5
  "repository": {
6
6
  "type": "git",