@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 +27 -20
- package/dist/index.cjs +84 -5
- package/dist/index.mjs +84 -5
- package/dist/llm-call.d.cts +16 -4
- package/dist/llm-call.d.ts +16 -4
- package/package.json +1 -1
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
|
|
30
|
-
`amsg-server`
|
|
31
|
-
`source: 'instant'`; the other three `messageType`s always
|
|
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
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
| `messageKind`
|
|
193
|
-
| `code`
|
|
194
|
-
| `message`
|
|
195
|
-
| `iteration`
|
|
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(
|
|
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
|
|
431
|
-
`'instant'`
|
|
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
|
|
274
|
-
const
|
|
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
|
-
) ||
|
|
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
|
|
189
|
-
const
|
|
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
|
-
) ||
|
|
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");
|
package/dist/llm-call.d.cts
CHANGED
|
@@ -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}
|
|
29
|
+
* @throws {Error} 上游答复了、但没给出能用的结果时抛,错误上带机读标注(见
|
|
27
30
|
* {@link buildUpstreamError}):`code` = `'LLM_CALL_FAILED'`、`llmStatus` =
|
|
28
31
|
* 上游的 HTTP 状态码、`providerCode` = provider 自己的错误码(拿得到才有)。
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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/dist/llm-call.d.ts
CHANGED
|
@@ -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}
|
|
29
|
+
* @throws {Error} 上游答复了、但没给出能用的结果时抛,错误上带机读标注(见
|
|
27
30
|
* {@link buildUpstreamError}):`code` = `'LLM_CALL_FAILED'`、`llmStatus` =
|
|
28
31
|
* 上游的 HTTP 状态码、`providerCode` = provider 自己的错误码(拿得到才有)。
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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