@deepseek-ai/dsh-llm 0.1.0-rc.6 → 0.1.0-rc.8
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.i18n.yaml +2 -2
- package/README.md +5 -1
- package/README.zh.md +5 -1
- package/lib/index.js +128 -9
- package/lib/types/assembler.d.ts +23 -4
- package/lib/types/assembler.js +51 -8
- package/lib/types/content.d.ts +13 -0
- package/lib/types/content.js +70 -0
- package/lib/types/retry-policy.d.ts +1 -1
- package/lib/types/retry-policy.js +6 -2
- package/lib/types/types.d.ts +22 -2
- package/package.json +10 -10
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377
|
|
6
|
+
README.zh.md: 51f034af632c19ca91c1d2f91a7c9c22714870e0
|
package/README.md
CHANGED
|
@@ -8,6 +8,10 @@ Provider-neutral LLM vocabulary and abstract service. This package defines the c
|
|
|
8
8
|
|
|
9
9
|
An adapter registry plus a single streaming call API, interceptable via a waterfall event.
|
|
10
10
|
|
|
11
|
+
### Retry policy
|
|
12
|
+
|
|
13
|
+
Each provider adapter supplies its resolved route policy. Omitting provider configuration uses bounded normal mode with five retries after the first request. Layered configuration may retain `maxRetries` or `retryableCodes` after changing `mode` to `always`; resolution ignores those inactive normal-mode fields and captures a pure always policy. This service stores the effective policy but does not execute retries.
|
|
14
|
+
|
|
11
15
|
### Public API
|
|
12
16
|
|
|
13
17
|
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
|
|
@@ -53,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
|
|
|
53
57
|
|
|
54
58
|
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
|
55
59
|
|
|
56
|
-
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
|
60
|
+
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content.
|
|
57
61
|
|
|
58
62
|
### Call configuration (`call-config.ts`)
|
|
59
63
|
|
package/README.zh.md
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
一个适配器注册表加单一流式调用接口,可通过 waterfall(瀑布式事件)拦截。
|
|
10
10
|
|
|
11
|
+
### 重试策略
|
|
12
|
+
|
|
13
|
+
每个提供方适配器都会提供解析后的路由策略。省略提供方配置时使用有界 normal mode,在首次请求后最多重试五次。分层配置把 `mode` 改为 `always` 后可能残留 `maxRetries` 或 `retryableCodes`;解析过程会忽略这些不再生效的 normal-mode 字段,并捕获纯 always 策略。本服务存储有效策略,但不执行重试。
|
|
14
|
+
|
|
11
15
|
### 公开 API
|
|
12
16
|
|
|
13
17
|
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的句柄还提供 `replace(providers)`:候选路由集合会在注册状态发生任何变化前完成整体验证,因此与另一适配器发生冲突时,当前路由仍保持注册并继续提供服务。替换会在一次同步操作中完成,不会出现可观察的空档。`replace([])` 合法,表示保留注册但不持有任何路由;初始注册则不得为空。
|
|
@@ -53,7 +57,7 @@
|
|
|
53
57
|
|
|
54
58
|
消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。
|
|
55
59
|
|
|
56
|
-
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler`
|
|
60
|
+
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。
|
|
57
61
|
|
|
58
62
|
### 调用配置(`call-config.ts`)
|
|
59
63
|
|
package/lib/index.js
CHANGED
|
@@ -353,7 +353,7 @@ function isHarnessError(value) {
|
|
|
353
353
|
*
|
|
354
354
|
* @module @deepseek-ai/dsh-llm/retry-policy
|
|
355
355
|
*/
|
|
356
|
-
const DEFAULT_MAX_RETRIES =
|
|
356
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
357
357
|
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
358
358
|
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
359
359
|
const DEFAULT_JITTER_RATIO = .1;
|
|
@@ -387,7 +387,12 @@ const NORMAL_POLICY_KEYS = new Set([
|
|
|
387
387
|
"retryableCodes",
|
|
388
388
|
"backoff"
|
|
389
389
|
]);
|
|
390
|
-
const ALWAYS_POLICY_KEYS = new Set([
|
|
390
|
+
const ALWAYS_POLICY_KEYS = new Set([
|
|
391
|
+
"mode",
|
|
392
|
+
"maxRetries",
|
|
393
|
+
"retryableCodes",
|
|
394
|
+
"backoff"
|
|
395
|
+
]);
|
|
391
396
|
const BACKOFF_KEYS = new Set([
|
|
392
397
|
"initialDelayMs",
|
|
393
398
|
"maxDelayMs",
|
|
@@ -639,6 +644,8 @@ function assertNever(value, context) {
|
|
|
639
644
|
//#endregion
|
|
640
645
|
//#region lib/types/content.js
|
|
641
646
|
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
|
|
647
|
+
/** Model-facing stand-in for an image removed to fit a provider request bound. */
|
|
648
|
+
const OFFLOADED_IMAGE_TEXT = "[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]";
|
|
642
649
|
/**
|
|
643
650
|
* True when typed model content contains an image block, walking nested
|
|
644
651
|
* tool-result content. This is the one recursive image walk shared by every
|
|
@@ -650,6 +657,73 @@ function assertNever(value, context) {
|
|
|
650
657
|
function contentHasImage(content) {
|
|
651
658
|
return content.some((block) => block.type === "image" || block.type === "tool-result" && contentHasImage(block.content));
|
|
652
659
|
}
|
|
660
|
+
/** Base64 length of raw image bytes, including padding. */
|
|
661
|
+
function base64Length(bytes) {
|
|
662
|
+
return Math.ceil(bytes / 3) * 4;
|
|
663
|
+
}
|
|
664
|
+
/** Collect base64 payload lengths in request and nested-block order. */
|
|
665
|
+
function collectImageLengths(blocks, lengths) {
|
|
666
|
+
for (const block of blocks) if (block.type === "image") lengths.push(base64Length(block.attachment.bytes));
|
|
667
|
+
else if (block.type === "tool-result") collectImageLengths(block.content, lengths);
|
|
668
|
+
}
|
|
669
|
+
/** Replace the first `remaining.count` image occurrences without mutating durable messages. */
|
|
670
|
+
function replaceOldestImages(blocks, remaining) {
|
|
671
|
+
let next;
|
|
672
|
+
for (const [index, block] of blocks.entries()) {
|
|
673
|
+
if (block.type === "image" && remaining.count > 0) {
|
|
674
|
+
remaining.count -= 1;
|
|
675
|
+
next ??= blocks.slice(0, index);
|
|
676
|
+
next.push({
|
|
677
|
+
type: "text",
|
|
678
|
+
text: OFFLOADED_IMAGE_TEXT
|
|
679
|
+
});
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (block.type === "tool-result") {
|
|
683
|
+
const content = replaceOldestImages(block.content, remaining);
|
|
684
|
+
if (content !== block.content) {
|
|
685
|
+
next ??= blocks.slice(0, index);
|
|
686
|
+
next.push({
|
|
687
|
+
...block,
|
|
688
|
+
content
|
|
689
|
+
});
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
next?.push(block);
|
|
694
|
+
}
|
|
695
|
+
return next ?? blocks;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Return transient request messages whose oldest images are replaced until
|
|
699
|
+
* their accumulated base64 payload fits the configured bound. The selection
|
|
700
|
+
* is deterministic from durable message order and attachment metadata; a
|
|
701
|
+
* provider can serialize the returned messages without reading omitted bytes.
|
|
702
|
+
* @param messages - complete request history, oldest first.
|
|
703
|
+
* @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
|
|
704
|
+
* @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
|
|
705
|
+
*/
|
|
706
|
+
function offloadRequestImages(messages, maxRequestImageBytes) {
|
|
707
|
+
if (maxRequestImageBytes === void 0) return messages;
|
|
708
|
+
const lengths = [];
|
|
709
|
+
for (const message of messages) collectImageLengths(message.content, lengths);
|
|
710
|
+
let total = lengths.reduce((sum, bytes) => sum + bytes, 0);
|
|
711
|
+
let count = 0;
|
|
712
|
+
for (const bytes of lengths) {
|
|
713
|
+
if (total <= maxRequestImageBytes) break;
|
|
714
|
+
total -= bytes;
|
|
715
|
+
count += 1;
|
|
716
|
+
}
|
|
717
|
+
if (count === 0) return messages;
|
|
718
|
+
const remaining = { count };
|
|
719
|
+
return messages.map((message) => {
|
|
720
|
+
const content = replaceOldestImages(message.content, remaining);
|
|
721
|
+
return content === message.content ? message : {
|
|
722
|
+
...message,
|
|
723
|
+
content
|
|
724
|
+
};
|
|
725
|
+
});
|
|
726
|
+
}
|
|
653
727
|
//#endregion
|
|
654
728
|
//#region lib/types/assembler.js
|
|
655
729
|
/**
|
|
@@ -664,7 +738,8 @@ function contentHasImage(content) {
|
|
|
664
738
|
* {@link ContentBlock}s and a final assistant {@link Message}.
|
|
665
739
|
*
|
|
666
740
|
* The agent loop feeds it while logging raw chunks for replay fidelity, then
|
|
667
|
-
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends
|
|
741
|
+
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
|
|
742
|
+
* or `interruptedBlocks()` when cancellation cut the stream short.
|
|
668
743
|
*
|
|
669
744
|
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
|
|
670
745
|
* an index already closed by `block-end` are ignored (malformed stream) so a
|
|
@@ -675,7 +750,7 @@ var BlockAssembler = class {
|
|
|
675
750
|
order = [];
|
|
676
751
|
_usage;
|
|
677
752
|
_finish;
|
|
678
|
-
_replayState
|
|
753
|
+
_replayState;
|
|
679
754
|
/**
|
|
680
755
|
* Feed one chunk into the assembly state.
|
|
681
756
|
* @param chunk - the next raw chunk, in stream order.
|
|
@@ -763,14 +838,54 @@ var BlockAssembler = class {
|
|
|
763
838
|
return partial;
|
|
764
839
|
}
|
|
765
840
|
/**
|
|
841
|
+
* The one shared keep/drop decision over all seen blocks: max-token
|
|
842
|
+
* truncation drops tool calls that cannot be executed safely. Emitted blocks
|
|
843
|
+
* and replay metadata both derive from this result, so they cannot disagree.
|
|
844
|
+
*/
|
|
845
|
+
assembled() {
|
|
846
|
+
const all = this.order.map((index) => this.assemble(this.mustGet(index), index));
|
|
847
|
+
const kept = this.finish.kind === "max-tokens" ? all.map((block) => block.type !== "tool-call") : void 0;
|
|
848
|
+
const blocks = kept === void 0 ? all : all.filter((_, position) => kept[position]);
|
|
849
|
+
const envelope = this._replayState;
|
|
850
|
+
if (envelope?.blocks === void 0) return {
|
|
851
|
+
blocks,
|
|
852
|
+
replay: envelope
|
|
853
|
+
};
|
|
854
|
+
if (envelope.blocks.length !== all.length) return {
|
|
855
|
+
blocks,
|
|
856
|
+
replay: void 0
|
|
857
|
+
};
|
|
858
|
+
return {
|
|
859
|
+
blocks,
|
|
860
|
+
replay: kept === void 0 || blocks.length === all.length ? envelope : {
|
|
861
|
+
response: envelope.response,
|
|
862
|
+
blocks: envelope.blocks.filter((_, position) => kept[position])
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
766
867
|
* Assemble all blocks seen so far, in stream order.
|
|
767
868
|
* @returns one block per seen index, except that max-token truncation drops
|
|
768
869
|
* tool calls that cannot be executed safely; an open block assembles from
|
|
769
870
|
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
|
770
871
|
*/
|
|
771
872
|
blocks() {
|
|
772
|
-
|
|
773
|
-
|
|
873
|
+
return this.assembled().blocks;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Assemble the prefix an interrupted stream can safely finalize: closed and
|
|
877
|
+
* open text/reasoning blocks with non-whitespace content, in stream order.
|
|
878
|
+
* Tool calls are omitted because interruption precedes dispatch; retaining
|
|
879
|
+
* one would require a fabricated result. Open unknown blocks are also omitted.
|
|
880
|
+
* @returns the kept blocks; empty when nothing streamed before the interruption.
|
|
881
|
+
*/
|
|
882
|
+
interruptedBlocks() {
|
|
883
|
+
return this.order.map((index) => {
|
|
884
|
+
const partial = this.mustGet(index);
|
|
885
|
+
const type = partial.block?.type ?? partial.blockType;
|
|
886
|
+
if (type !== "text" && type !== "reasoning") return void 0;
|
|
887
|
+
return this.assemble(partial, index);
|
|
888
|
+
}).filter((block) => (block?.type === "text" || block?.type === "reasoning") && block.text.trim() !== "");
|
|
774
889
|
}
|
|
775
890
|
/** Usage from the `usage` chunk; undefined until one arrives. */
|
|
776
891
|
get usage() {
|
|
@@ -780,9 +895,13 @@ var BlockAssembler = class {
|
|
|
780
895
|
get finish() {
|
|
781
896
|
return this._finish ?? { kind: "stop" };
|
|
782
897
|
}
|
|
783
|
-
/**
|
|
898
|
+
/**
|
|
899
|
+
* Replay metadata from the terminal finish chunk, if any, with per-block
|
|
900
|
+
* entries pruned in step with {@link blocks}. Undefined when the envelope's
|
|
901
|
+
* entries do not align with the emitted blocks.
|
|
902
|
+
*/
|
|
784
903
|
get replayState() {
|
|
785
|
-
return this.
|
|
904
|
+
return this.assembled().replay;
|
|
786
905
|
}
|
|
787
906
|
/**
|
|
788
907
|
* The assembled assistant message.
|
|
@@ -1404,4 +1523,4 @@ function adapterFailureChunk(error, signal) {
|
|
|
1404
1523
|
};
|
|
1405
1524
|
}
|
|
1406
1525
|
//#endregion
|
|
1407
|
-
export { APP_IDENTITY, BlockAssembler, CONTEXT_SUMMARY_MAX_CHARS, CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, HarnessError, INVALID_CREDENTIAL_CODE, LlmAdapter, LlmError, LlmRuntime, LlmRuntime as default, MessageId, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertNever, assertUsableApiKey, attributionHeaders, boundContextSummary, callConfigEquals, contentHasImage, createAssistantMessage, createMessage, createToolResultMessage, createUserMessage, deepFreeze, errorChain, freezeMessage, isAgentLoopRequest, isContextWindowExceededError, isHarnessError, isQuotaExceededError, isTokenDelta, markAgentLoopRequest, normalizeApiKey, resolveRetryPolicy, userAgent };
|
|
1526
|
+
export { APP_IDENTITY, BlockAssembler, CONTEXT_SUMMARY_MAX_CHARS, CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, HarnessError, INVALID_CREDENTIAL_CODE, LlmAdapter, LlmError, LlmRuntime, LlmRuntime as default, MessageId, OFFLOADED_IMAGE_TEXT, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertNever, assertUsableApiKey, attributionHeaders, boundContextSummary, callConfigEquals, contentHasImage, createAssistantMessage, createMessage, createToolResultMessage, createUserMessage, deepFreeze, errorChain, freezeMessage, isAgentLoopRequest, isContextWindowExceededError, isHarnessError, isQuotaExceededError, isTokenDelta, markAgentLoopRequest, normalizeApiKey, offloadRequestImages, resolveRetryPolicy, userAgent };
|
package/lib/types/assembler.d.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
* @module @deepseek-ai/dsh-llm/assembler
|
|
7
7
|
*/
|
|
8
8
|
import type { Message, MessageSource } from './message.ts';
|
|
9
|
-
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from './types.ts';
|
|
9
|
+
import type { ContentBlock, FinishReason, ReplayEnvelope, StreamChunk, TokenUsage } from './types.ts';
|
|
10
10
|
/**
|
|
11
11
|
* Incrementally assembles raw {@link StreamChunk}s into complete
|
|
12
12
|
* {@link ContentBlock}s and a final assistant {@link Message}.
|
|
13
13
|
*
|
|
14
14
|
* The agent loop feeds it while logging raw chunks for replay fidelity, then
|
|
15
|
-
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends
|
|
15
|
+
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
|
|
16
|
+
* or `interruptedBlocks()` when cancellation cut the stream short.
|
|
16
17
|
*
|
|
17
18
|
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
|
|
18
19
|
* an index already closed by `block-end` are ignored (malformed stream) so a
|
|
@@ -33,6 +34,12 @@ export declare class BlockAssembler {
|
|
|
33
34
|
private assemble;
|
|
34
35
|
/** Invariant accessor: every index in `order` has a partial. */
|
|
35
36
|
private mustGet;
|
|
37
|
+
/**
|
|
38
|
+
* The one shared keep/drop decision over all seen blocks: max-token
|
|
39
|
+
* truncation drops tool calls that cannot be executed safely. Emitted blocks
|
|
40
|
+
* and replay metadata both derive from this result, so they cannot disagree.
|
|
41
|
+
*/
|
|
42
|
+
private assembled;
|
|
36
43
|
/**
|
|
37
44
|
* Assemble all blocks seen so far, in stream order.
|
|
38
45
|
* @returns one block per seen index, except that max-token truncation drops
|
|
@@ -40,12 +47,24 @@ export declare class BlockAssembler {
|
|
|
40
47
|
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
|
41
48
|
*/
|
|
42
49
|
blocks(): ContentBlock[];
|
|
50
|
+
/**
|
|
51
|
+
* Assemble the prefix an interrupted stream can safely finalize: closed and
|
|
52
|
+
* open text/reasoning blocks with non-whitespace content, in stream order.
|
|
53
|
+
* Tool calls are omitted because interruption precedes dispatch; retaining
|
|
54
|
+
* one would require a fabricated result. Open unknown blocks are also omitted.
|
|
55
|
+
* @returns the kept blocks; empty when nothing streamed before the interruption.
|
|
56
|
+
*/
|
|
57
|
+
interruptedBlocks(): ContentBlock[];
|
|
43
58
|
/** Usage from the `usage` chunk; undefined until one arrives. */
|
|
44
59
|
get usage(): TokenUsage | undefined;
|
|
45
60
|
/** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
|
|
46
61
|
get finish(): FinishReason;
|
|
47
|
-
/**
|
|
48
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Replay metadata from the terminal finish chunk, if any, with per-block
|
|
64
|
+
* entries pruned in step with {@link blocks}. Undefined when the envelope's
|
|
65
|
+
* entries do not align with the emitted blocks.
|
|
66
|
+
*/
|
|
67
|
+
get replayState(): ReplayEnvelope | undefined;
|
|
49
68
|
/**
|
|
50
69
|
* The assembled assistant message.
|
|
51
70
|
* @param source - producer attribution for the assembled message.
|
package/lib/types/assembler.js
CHANGED
|
@@ -13,7 +13,8 @@ import { createMessage } from "./message.js";
|
|
|
13
13
|
* {@link ContentBlock}s and a final assistant {@link Message}.
|
|
14
14
|
*
|
|
15
15
|
* The agent loop feeds it while logging raw chunks for replay fidelity, then
|
|
16
|
-
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends
|
|
16
|
+
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
|
|
17
|
+
* or `interruptedBlocks()` when cancellation cut the stream short.
|
|
17
18
|
*
|
|
18
19
|
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
|
|
19
20
|
* an index already closed by `block-end` are ignored (malformed stream) so a
|
|
@@ -24,7 +25,7 @@ export class BlockAssembler {
|
|
|
24
25
|
order = [];
|
|
25
26
|
_usage;
|
|
26
27
|
_finish;
|
|
27
|
-
_replayState
|
|
28
|
+
_replayState;
|
|
28
29
|
/**
|
|
29
30
|
* Feed one chunk into the assembly state.
|
|
30
31
|
* @param chunk - the next raw chunk, in stream order.
|
|
@@ -112,6 +113,29 @@ export class BlockAssembler {
|
|
|
112
113
|
throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`);
|
|
113
114
|
return partial;
|
|
114
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The one shared keep/drop decision over all seen blocks: max-token
|
|
118
|
+
* truncation drops tool calls that cannot be executed safely. Emitted blocks
|
|
119
|
+
* and replay metadata both derive from this result, so they cannot disagree.
|
|
120
|
+
*/
|
|
121
|
+
assembled() {
|
|
122
|
+
const all = this.order.map(index => this.assemble(this.mustGet(index), index));
|
|
123
|
+
const kept = this.finish.kind === 'max-tokens'
|
|
124
|
+
? all.map(block => block.type !== 'tool-call')
|
|
125
|
+
: undefined;
|
|
126
|
+
const blocks = kept === undefined ? all : all.filter((_, position) => kept[position]);
|
|
127
|
+
const envelope = this._replayState;
|
|
128
|
+
if (envelope?.blocks === undefined)
|
|
129
|
+
return { blocks, replay: envelope };
|
|
130
|
+
if (envelope.blocks.length !== all.length)
|
|
131
|
+
return { blocks, replay: undefined };
|
|
132
|
+
return {
|
|
133
|
+
blocks,
|
|
134
|
+
replay: kept === undefined || blocks.length === all.length
|
|
135
|
+
? envelope
|
|
136
|
+
: { response: envelope.response, blocks: envelope.blocks.filter((_, position) => kept[position]) },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
115
139
|
/**
|
|
116
140
|
* Assemble all blocks seen so far, in stream order.
|
|
117
141
|
* @returns one block per seen index, except that max-token truncation drops
|
|
@@ -119,10 +143,25 @@ export class BlockAssembler {
|
|
|
119
143
|
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
|
120
144
|
*/
|
|
121
145
|
blocks() {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
return this.assembled().blocks;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Assemble the prefix an interrupted stream can safely finalize: closed and
|
|
150
|
+
* open text/reasoning blocks with non-whitespace content, in stream order.
|
|
151
|
+
* Tool calls are omitted because interruption precedes dispatch; retaining
|
|
152
|
+
* one would require a fabricated result. Open unknown blocks are also omitted.
|
|
153
|
+
* @returns the kept blocks; empty when nothing streamed before the interruption.
|
|
154
|
+
*/
|
|
155
|
+
interruptedBlocks() {
|
|
156
|
+
return this.order
|
|
157
|
+
.map((index) => {
|
|
158
|
+
const partial = this.mustGet(index);
|
|
159
|
+
const type = partial.block?.type ?? partial.blockType;
|
|
160
|
+
if (type !== 'text' && type !== 'reasoning')
|
|
161
|
+
return undefined;
|
|
162
|
+
return this.assemble(partial, index);
|
|
163
|
+
})
|
|
164
|
+
.filter((block) => (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '');
|
|
126
165
|
}
|
|
127
166
|
/** Usage from the `usage` chunk; undefined until one arrives. */
|
|
128
167
|
get usage() {
|
|
@@ -132,9 +171,13 @@ export class BlockAssembler {
|
|
|
132
171
|
get finish() {
|
|
133
172
|
return this._finish ?? { kind: 'stop' };
|
|
134
173
|
}
|
|
135
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* Replay metadata from the terminal finish chunk, if any, with per-block
|
|
176
|
+
* entries pruned in step with {@link blocks}. Undefined when the envelope's
|
|
177
|
+
* entries do not align with the emitted blocks.
|
|
178
|
+
*/
|
|
136
179
|
get replayState() {
|
|
137
|
-
return this.
|
|
180
|
+
return this.assembled().replay;
|
|
138
181
|
}
|
|
139
182
|
/**
|
|
140
183
|
* The assembled assistant message.
|
package/lib/types/content.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
|
|
2
2
|
import type { ContentBlock } from './types.ts';
|
|
3
|
+
import type { Message } from './message.ts';
|
|
4
|
+
/** Model-facing stand-in for an image removed to fit a provider request bound. */
|
|
5
|
+
export declare const OFFLOADED_IMAGE_TEXT = "[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]";
|
|
3
6
|
/**
|
|
4
7
|
* True when typed model content contains an image block, walking nested
|
|
5
8
|
* tool-result content. This is the one recursive image walk shared by every
|
|
@@ -9,4 +12,14 @@ import type { ContentBlock } from './types.ts';
|
|
|
9
12
|
* @returns whether any nested block is an image.
|
|
10
13
|
*/
|
|
11
14
|
export declare function contentHasImage(content: readonly ContentBlock[]): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Return transient request messages whose oldest images are replaced until
|
|
17
|
+
* their accumulated base64 payload fits the configured bound. The selection
|
|
18
|
+
* is deterministic from durable message order and attachment metadata; a
|
|
19
|
+
* provider can serialize the returned messages without reading omitted bytes.
|
|
20
|
+
* @param messages - complete request history, oldest first.
|
|
21
|
+
* @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
|
|
22
|
+
* @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
|
|
23
|
+
*/
|
|
24
|
+
export declare function offloadRequestImages(messages: readonly Message[], maxRequestImageBytes: number | undefined): readonly Message[];
|
|
12
25
|
//# sourceMappingURL=content.d.ts.map
|
package/lib/types/content.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
|
|
2
|
+
/** Model-facing stand-in for an image removed to fit a provider request bound. */
|
|
3
|
+
export const OFFLOADED_IMAGE_TEXT = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]';
|
|
2
4
|
/**
|
|
3
5
|
* True when typed model content contains an image block, walking nested
|
|
4
6
|
* tool-result content. This is the one recursive image walk shared by every
|
|
@@ -11,4 +13,72 @@ export function contentHasImage(content) {
|
|
|
11
13
|
return content.some(block => block.type === 'image'
|
|
12
14
|
|| (block.type === 'tool-result' && contentHasImage(block.content)));
|
|
13
15
|
}
|
|
16
|
+
/** Base64 length of raw image bytes, including padding. */
|
|
17
|
+
function base64Length(bytes) {
|
|
18
|
+
return Math.ceil(bytes / 3) * 4;
|
|
19
|
+
}
|
|
20
|
+
/** Collect base64 payload lengths in request and nested-block order. */
|
|
21
|
+
function collectImageLengths(blocks, lengths) {
|
|
22
|
+
for (const block of blocks) {
|
|
23
|
+
if (block.type === 'image') {
|
|
24
|
+
lengths.push(base64Length(block.attachment.bytes));
|
|
25
|
+
}
|
|
26
|
+
else if (block.type === 'tool-result') {
|
|
27
|
+
collectImageLengths(block.content, lengths);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Replace the first `remaining.count` image occurrences without mutating durable messages. */
|
|
32
|
+
function replaceOldestImages(blocks, remaining) {
|
|
33
|
+
let next;
|
|
34
|
+
for (const [index, block] of blocks.entries()) {
|
|
35
|
+
if (block.type === 'image' && remaining.count > 0) {
|
|
36
|
+
remaining.count -= 1;
|
|
37
|
+
next ??= blocks.slice(0, index);
|
|
38
|
+
next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (block.type === 'tool-result') {
|
|
42
|
+
const content = replaceOldestImages(block.content, remaining);
|
|
43
|
+
if (content !== block.content) {
|
|
44
|
+
next ??= blocks.slice(0, index);
|
|
45
|
+
next.push({ ...block, content });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
next?.push(block);
|
|
50
|
+
}
|
|
51
|
+
return next ?? blocks;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Return transient request messages whose oldest images are replaced until
|
|
55
|
+
* their accumulated base64 payload fits the configured bound. The selection
|
|
56
|
+
* is deterministic from durable message order and attachment metadata; a
|
|
57
|
+
* provider can serialize the returned messages without reading omitted bytes.
|
|
58
|
+
* @param messages - complete request history, oldest first.
|
|
59
|
+
* @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
|
|
60
|
+
* @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
|
|
61
|
+
*/
|
|
62
|
+
export function offloadRequestImages(messages, maxRequestImageBytes) {
|
|
63
|
+
if (maxRequestImageBytes === undefined)
|
|
64
|
+
return messages;
|
|
65
|
+
const lengths = [];
|
|
66
|
+
for (const message of messages)
|
|
67
|
+
collectImageLengths(message.content, lengths);
|
|
68
|
+
let total = lengths.reduce((sum, bytes) => sum + bytes, 0);
|
|
69
|
+
let count = 0;
|
|
70
|
+
for (const bytes of lengths) {
|
|
71
|
+
if (total <= maxRequestImageBytes)
|
|
72
|
+
break;
|
|
73
|
+
total -= bytes;
|
|
74
|
+
count += 1;
|
|
75
|
+
}
|
|
76
|
+
if (count === 0)
|
|
77
|
+
return messages;
|
|
78
|
+
const remaining = { count };
|
|
79
|
+
return messages.map((message) => {
|
|
80
|
+
const content = replaceOldestImages(message.content, remaining);
|
|
81
|
+
return content === message.content ? message : { ...message, content };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
14
84
|
//# sourceMappingURL=content.js.map
|
|
@@ -20,7 +20,7 @@ export interface BackoffConfig {
|
|
|
20
20
|
export interface NormalRetryPolicyConfig {
|
|
21
21
|
/** Retry only configured transient failure codes. */
|
|
22
22
|
mode: 'normal';
|
|
23
|
-
/** Maximum eligible retries after the first request (default
|
|
23
|
+
/** Maximum eligible retries after the first request (default 5). */
|
|
24
24
|
maxRetries?: number;
|
|
25
25
|
/** Stable failure codes eligible for this policy. */
|
|
26
26
|
retryableCodes?: string[];
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import z from '@deepseek-ai/schemastery';
|
|
10
10
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
|
|
11
11
|
import { EMPTY_RESPONSE_CODE } from "./error.js";
|
|
12
|
-
const DEFAULT_MAX_RETRIES =
|
|
12
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
13
13
|
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
14
14
|
const DEFAULT_MAX_DELAY_MS = 10_000;
|
|
15
15
|
const DEFAULT_JITTER_RATIO = 0.1;
|
|
@@ -43,7 +43,11 @@ export const RetryPolicySchema = z.union([
|
|
|
43
43
|
const NORMAL_POLICY_KEYS = new Set([
|
|
44
44
|
'mode', 'maxRetries', 'retryableCodes', 'backoff',
|
|
45
45
|
]);
|
|
46
|
-
|
|
46
|
+
// Layered configuration can retain normal-only fields after switching modes;
|
|
47
|
+
// always mode ignores those inactive values while still rejecting unknown keys.
|
|
48
|
+
const ALWAYS_POLICY_KEYS = new Set([
|
|
49
|
+
'mode', 'maxRetries', 'retryableCodes', 'backoff',
|
|
50
|
+
]);
|
|
47
51
|
const BACKOFF_KEYS = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']);
|
|
48
52
|
function validateKeys(value, allowed, path) {
|
|
49
53
|
for (const key of Object.keys(value)) {
|
package/lib/types/types.d.ts
CHANGED
|
@@ -256,6 +256,26 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
|
|
|
256
256
|
/** Adapter-owned selectable reasoning levels when exposed. */
|
|
257
257
|
reasoning?: LlmModelReasoningInfo;
|
|
258
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Adapter-private lossless-JSON state for replaying a successful response,
|
|
261
|
+
* carried by a terminal `finish` chunk and stored on the assembled assistant
|
|
262
|
+
* message's model source. Both halves stay opaque to the harness; only the
|
|
263
|
+
* split is shared vocabulary, so assembly can keep stored metadata aligned
|
|
264
|
+
* with stored content without reading either half.
|
|
265
|
+
*/
|
|
266
|
+
export interface ReplayEnvelope {
|
|
267
|
+
/** Response-level adapter-private metadata (ids, native stop reason). */
|
|
268
|
+
response: unknown;
|
|
269
|
+
/**
|
|
270
|
+
* Per-block adapter-private metadata, one entry per emitted block in
|
|
271
|
+
* first-seen stream order. When assembly drops a block it drops the entry at
|
|
272
|
+
* the same position; entries whose length does not match the emitted block
|
|
273
|
+
* count discard the whole envelope. An adapter whose metadata is independent
|
|
274
|
+
* of block structure omits this field and the envelope passes through
|
|
275
|
+
* assembly unchanged.
|
|
276
|
+
*/
|
|
277
|
+
blocks?: readonly unknown[];
|
|
278
|
+
}
|
|
259
279
|
/**
|
|
260
280
|
* Raw streaming protocol emitted by adapters.
|
|
261
281
|
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
|
@@ -292,8 +312,8 @@ export type StreamChunk = {
|
|
|
292
312
|
} | {
|
|
293
313
|
type: 'finish';
|
|
294
314
|
reason: FinishReason;
|
|
295
|
-
/**
|
|
296
|
-
replayState?:
|
|
315
|
+
/** Replay metadata for a successful response; see {@link ReplayEnvelope}. */
|
|
316
|
+
replayState?: ReplayEnvelope;
|
|
297
317
|
};
|
|
298
318
|
/**
|
|
299
319
|
* JSON-schema description of a tool, as sent to the model.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-llm",
|
|
3
3
|
"description": "Provider-neutral LLM service interface for the DeepSeek Harness",
|
|
4
|
-
"version": "0.1.0-rc.
|
|
4
|
+
"version": "0.1.0-rc.8",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -45,20 +45,20 @@
|
|
|
45
45
|
],
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.
|
|
48
|
+
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.8",
|
|
49
|
+
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
|
|
50
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
|
51
|
+
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
|
|
52
52
|
"@deepseek-ai/cordis": "^4.0.1"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.
|
|
59
|
-
"@deepseek-ai/dsh-brand": "^0.1.0-rc.
|
|
60
|
-
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.
|
|
61
|
-
"@deepseek-ai/
|
|
62
|
-
"@deepseek-ai/
|
|
58
|
+
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.8",
|
|
59
|
+
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
|
|
60
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
|
61
|
+
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
|
|
62
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
63
63
|
}
|
|
64
64
|
}
|