@stackstackstack/dsh-llm 0.1.5

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.
@@ -0,0 +1,13 @@
1
+ /** Package-owned LLM stream-protocol invariants. @module @stackstackstack/dsh-llm/invariant */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ /** Cordis companion plugin name. */
4
+ export declare const name = "llm-invariant";
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export declare const inject: string[];
7
+ /**
8
+ * Register the LLM invariant companion.
9
+ * @param ctx - Cordis context carrying the invariant service.
10
+ * @returns the installed registration's disposer after setup succeeds.
11
+ */
12
+ export declare const apply: (ctx: Context) => Promise<() => void>;
13
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,100 @@
1
+ /** Package-owned LLM stream-protocol invariants. @module @stackstackstack/dsh-llm/invariant */
2
+ const PACKAGE_NAME = '@stackstackstack/dsh-llm';
3
+ /** Cordis companion plugin name. */
4
+ export const name = 'llm-invariant';
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export const inject = ['invariants'];
7
+ /** Require one chunk index to be a non-negative safe integer. */
8
+ function validateIndex(index, fail) {
9
+ if (!Number.isSafeInteger(index) || index < 0) {
10
+ fail(`LLM stream block index must be a non-negative safe integer, got ${index}`);
11
+ }
12
+ }
13
+ /** Require a delta to address an open block of its matching type. */
14
+ function validateDelta(open, index, expected, fail) {
15
+ validateIndex(index, fail);
16
+ const actual = open.get(index);
17
+ if (actual !== expected) {
18
+ fail(`${expected} delta at index ${index} requires an open ${expected} block, got ${String(actual)}`);
19
+ }
20
+ }
21
+ /** Wrap one provider stream and enforce its grammar as chunks are consumed. */
22
+ async function* validateStream(source, fail) {
23
+ const open = new Map();
24
+ let usageSeen = false;
25
+ let finished = false;
26
+ for await (const chunk of source) {
27
+ if (finished)
28
+ fail(`LLM stream emitted ${chunk.type} after terminal finish`);
29
+ switch (chunk.type) {
30
+ case 'block-start':
31
+ validateIndex(chunk.index, fail);
32
+ if (open.has(chunk.index))
33
+ fail(`LLM stream repeated block-start index ${chunk.index}`);
34
+ open.set(chunk.index, chunk.blockType);
35
+ break;
36
+ case 'text-delta':
37
+ validateDelta(open, chunk.index, 'text', fail);
38
+ break;
39
+ case 'reasoning-delta':
40
+ validateDelta(open, chunk.index, 'reasoning', fail);
41
+ break;
42
+ case 'tool-call-delta':
43
+ validateDelta(open, chunk.index, 'tool-call', fail);
44
+ break;
45
+ case 'block-end': {
46
+ validateIndex(chunk.index, fail);
47
+ const blockType = open.get(chunk.index);
48
+ if (blockType === undefined)
49
+ fail(`LLM stream block-end index ${chunk.index} has no open block`);
50
+ if (chunk.block.type !== blockType) {
51
+ fail(`LLM stream block-end index ${chunk.index} closes ${chunk.block.type}, expected ${blockType}`);
52
+ }
53
+ open.delete(chunk.index);
54
+ break;
55
+ }
56
+ case 'usage':
57
+ if (usageSeen)
58
+ fail('LLM stream emitted usage more than once');
59
+ usageSeen = true;
60
+ break;
61
+ case 'finish':
62
+ if (open.size > 0 && chunk.reason.kind !== 'error' && chunk.reason.kind !== 'aborted') {
63
+ fail(`LLM stream finished with ${open.size} open block(s)`);
64
+ }
65
+ finished = true;
66
+ break;
67
+ }
68
+ yield chunk;
69
+ }
70
+ if (!finished)
71
+ fail('LLM stream ended without a terminal finish chunk');
72
+ }
73
+ /** Install validation around every provider stream. */
74
+ const install = (ctx, fail) => {
75
+ ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true });
76
+ ctx.on('llm/adapters-updated', () => {
77
+ // A disposer-time emit can outlive the service-store entry during whole-
78
+ // context teardown; only a live service promises a readable registry.
79
+ const llm = ctx.get('llm');
80
+ if (llm === undefined)
81
+ return;
82
+ for (const provider of llm.listProviders()) {
83
+ try {
84
+ llm.providerRetryPolicy(provider.id);
85
+ }
86
+ catch {
87
+ // Reaching here IS the violation: the notification promised a readable
88
+ // registry, and only that broken promise can make the lookup throw.
89
+ fail(`llm/adapters-updated fired while provider "${provider.id}" has no readable registration`);
90
+ }
91
+ }
92
+ }, { global: true });
93
+ };
94
+ /**
95
+ * Register the LLM invariant companion.
96
+ * @param ctx - Cordis context carrying the invariant service.
97
+ * @returns the installed registration's disposer after setup succeeds.
98
+ */
99
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
100
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,206 @@
1
+ /** Message value types, identity, and immutable construction helpers. */
2
+ import { MessageId, type CallId } from './brand.ts';
3
+ import type { ContentBlock, StreamChunk, ToolResultBlock } from './types.ts';
4
+ /** Provider/model identity and adapter-private replay data for an assistant message. */
5
+ export interface AssistantProvenance {
6
+ /** Provider route that produced the message. */
7
+ provider: string;
8
+ /** Provider model id that produced the message. */
9
+ model: string;
10
+ /**
11
+ * Lossless-JSON adapter state needed to replay the provider response.
12
+ * `LlmRuntime` exposes it to a target adapter only when that adapter instance
13
+ * currently owns both this historical provider and the target provider.
14
+ */
15
+ replayState?: unknown;
16
+ }
17
+ /** Required source of an assistant message produced by a routed model. */
18
+ export interface ModelMessageSource extends AssistantProvenance {
19
+ kind: 'model';
20
+ }
21
+ /** Required source of a user-role message carrying one tool result. */
22
+ export interface ToolMessageSource {
23
+ kind: 'tool';
24
+ callId: CallId;
25
+ }
26
+ /**
27
+ * The kind of information in producer-supplied context, declared by the
28
+ * producer beside its provenance.
29
+ *
30
+ * `MessageSource.kind` answers *who produced this*; `form` answers *what kind
31
+ * of thing it is*, and the two axes are deliberately independent — several
32
+ * producers share one form, and one producer may emit more than one form over
33
+ * a session.
34
+ *
35
+ * The vocabulary is SEMANTIC, never visual: a value states that the content is
36
+ * a file's instructions or a catalog of available items, and a consumer decides
37
+ * what that looks like. Colors, icons, ordering, and collapse defaults are the
38
+ * consumer's business and must not enter this union. It grows one value at a
39
+ * time as producers gain the structured fields their form needs; an absent or
40
+ * unknown value is the documented default, presented as opaque content.
41
+ */
42
+ export type ContextForm =
43
+ /** Instructions read out of workspace files the model is expected to follow. */
44
+ 'instructions'
45
+ /** A catalog of items available in this session, republished as it changes. */
46
+ | 'catalog'
47
+ /** Current state, where a later snapshot from the same producer supersedes an earlier one. */
48
+ | 'snapshot'
49
+ /** A one-off account of something that just happened; it supersedes nothing. */
50
+ | 'notice'
51
+ /** A message another agent addressed to this one. */
52
+ | 'relay'
53
+ /** Material lifted out of another session's log, possibly reduced on the way in. */
54
+ | 'recall';
55
+ /** One named contribution to a `snapshot`-form context, in assembly order. */
56
+ export interface ContextSnapshotSection {
57
+ /** The contributing subsystem's name. */
58
+ readonly name: string;
59
+ /** That contribution's model-facing text, exactly as assembled. */
60
+ readonly text: string;
61
+ }
62
+ /**
63
+ * Producer-declared {@link ContextForm} and the fields that form requires,
64
+ * mixed into the source types that carry one.
65
+ *
66
+ * Discriminated by `form` so a producer cannot select a form without the
67
+ * fields needed to present it: a `notice` must record its one-line
68
+ * account, a `snapshot` its sections. Omitting `form` stays valid — an
69
+ * undeclared context is the documented default.
70
+ */
71
+ export type ContextFormed = {
72
+ readonly form?: never;
73
+ } | {
74
+ readonly form: 'instructions';
75
+ } | {
76
+ readonly form: 'catalog';
77
+ } | {
78
+ readonly form: 'snapshot';
79
+ /** The named contributions this snapshot assembled, in order. */
80
+ readonly sections: readonly ContextSnapshotSection[];
81
+ } | {
82
+ readonly form: 'notice';
83
+ /** One-line account of what happened, shown without expanding the row. */
84
+ readonly summary: string;
85
+ } | {
86
+ readonly form: 'relay';
87
+ } | {
88
+ readonly form: 'recall';
89
+ };
90
+ /**
91
+ * Where a message (or injected content) came from.
92
+ * Merge-extensible sum type — plugins add their own `kind`s.
93
+ */
94
+ export interface MessageSourceMap {
95
+ user: {
96
+ kind: 'user';
97
+ };
98
+ plugin: {
99
+ kind: 'plugin';
100
+ plugin: string;
101
+ } & ContextFormed;
102
+ model: ModelMessageSource;
103
+ tool: ToolMessageSource;
104
+ }
105
+ /**
106
+ * Bound for a `notice` summary. The account rides a collapsed transcript row
107
+ * and is committed to the durable log, while its inputs — task labels, goal
108
+ * objectives, tool arguments — are caller text with no length of their own.
109
+ */
110
+ export declare const CONTEXT_SUMMARY_MAX_CHARS = 120;
111
+ /**
112
+ * Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
113
+ * @param summary - the producer's one-line account, of any length.
114
+ * @returns the account, ellipsized when it exceeds the bound.
115
+ */
116
+ export declare function boundContextSummary(summary: string): string;
117
+ /** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
118
+ export type MessageSource = MessageSourceMap[keyof MessageSourceMap];
119
+ /** One immutable message representation shared by delivery, durable history, and model requests. */
120
+ export interface Message {
121
+ /** Stable identity preserved across every representation boundary. */
122
+ readonly id: MessageId;
123
+ /** Provider-neutral conversation role. */
124
+ readonly role: 'system' | 'user' | 'assistant';
125
+ /** Exact model-facing blocks. */
126
+ readonly content: ContentBlock[];
127
+ /** Required source fields supplied by the producer. */
128
+ readonly source: MessageSource;
129
+ }
130
+ /** A user-role specialization of the one shared message representation. */
131
+ export interface UserMessage extends Message {
132
+ readonly role: 'user';
133
+ }
134
+ /** A model-produced assistant specialization of the shared message representation. */
135
+ export interface AssistantMessage extends Message {
136
+ readonly role: 'assistant';
137
+ readonly source: ModelMessageSource;
138
+ }
139
+ /** A tool-result specialization whose model-facing block retains call correlation. */
140
+ export interface ToolResultMessage extends Message {
141
+ readonly role: 'user';
142
+ readonly content: [ToolResultBlock];
143
+ readonly source: ToolMessageSource;
144
+ }
145
+ type NewMessage = Omit<Message, 'id'>;
146
+ type NewUserMessage = Omit<UserMessage, 'id' | 'role'>;
147
+ type NewAssistantMessage = Omit<AssistantMessage, 'id' | 'role' | 'source'> & {
148
+ readonly source: Omit<ModelMessageSource, 'kind'> & {
149
+ readonly kind?: never;
150
+ };
151
+ };
152
+ /**
153
+ * Detach and deep-freeze a message whose identity already exists.
154
+ * @param message - complete message, including its stable identity.
155
+ * @returns an immutable snapshot that preserves the identity.
156
+ */
157
+ export declare function freezeMessage<T extends Message>(message: T): T;
158
+ /**
159
+ * Create one identified message and freeze it before publication.
160
+ * @param input - complete role, content, and source for a new message.
161
+ * @returns an immutable message with a fresh stable identity.
162
+ */
163
+ export declare function createMessage<T extends NewMessage>(input: T & {
164
+ readonly id?: never;
165
+ }): T & Pick<Message, 'id'>;
166
+ /**
167
+ * Create one identified user-role message and freeze it before publication.
168
+ * @param input - complete content and source for a new user message.
169
+ * @returns an immutable user message with a fresh stable identity.
170
+ */
171
+ export declare function createUserMessage<T extends NewUserMessage>(input: T & {
172
+ readonly id?: never;
173
+ readonly role?: never;
174
+ }): T & Pick<UserMessage, 'id' | 'role'>;
175
+ /**
176
+ * Create one identified model-produced assistant message and freeze it before publication.
177
+ * @param input - complete content plus the provider, model, and optional replay state for a new assistant message.
178
+ * @returns an immutable assistant message with fixed role/source tags and a fresh stable identity.
179
+ */
180
+ export declare function createAssistantMessage(input: NewAssistantMessage & {
181
+ readonly id?: never;
182
+ readonly role?: never;
183
+ }): AssistantMessage;
184
+ /** Input whose acceptance creates one tool-result message. */
185
+ export interface ToolResultMessageInput {
186
+ readonly callId: CallId;
187
+ readonly content: ContentBlock[];
188
+ readonly isError: boolean;
189
+ }
190
+ /**
191
+ * Create and freeze one identified tool-result message.
192
+ * @param input - call identity, raw result blocks, and outcome.
193
+ * @returns an immutable user-role tool-result message.
194
+ */
195
+ export declare function createToolResultMessage(input: ToolResultMessageInput): ToolResultMessage;
196
+ /**
197
+ * Whether a stream chunk carries visible model output (the first-token
198
+ * boundary shared by client step timing and the whole-log sessionStats
199
+ * projection). Empty deltas (heartbeats, empty tool-call frames) do not count
200
+ * as a first token.
201
+ * @param chunk - the stream chunk to test.
202
+ * @returns true when the chunk contains a non-empty text/reasoning/tool delta.
203
+ */
204
+ export declare function isTokenDelta(chunk: StreamChunk): boolean;
205
+ export {};
206
+ //# sourceMappingURL=message.d.ts.map
@@ -0,0 +1,100 @@
1
+ /** Message value types, identity, and immutable construction helpers. */
2
+ import { MessageId } from "./brand.js";
3
+ import { deepFreeze } from "./call-config.js";
4
+ /**
5
+ * Bound for a `notice` summary. The account rides a collapsed transcript row
6
+ * and is committed to the durable log, while its inputs — task labels, goal
7
+ * objectives, tool arguments — are caller text with no length of their own.
8
+ */
9
+ export const CONTEXT_SUMMARY_MAX_CHARS = 120;
10
+ /**
11
+ * Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
12
+ * @param summary - the producer's one-line account, of any length.
13
+ * @returns the account, ellipsized when it exceeds the bound.
14
+ */
15
+ export function boundContextSummary(summary) {
16
+ return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
17
+ ? summary
18
+ : `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`;
19
+ }
20
+ /**
21
+ * Detach and deep-freeze a message whose identity already exists.
22
+ * @param message - complete message, including its stable identity.
23
+ * @returns an immutable snapshot that preserves the identity.
24
+ */
25
+ export function freezeMessage(message) {
26
+ return deepFreeze(structuredClone(message));
27
+ }
28
+ /**
29
+ * Create one identified message and freeze it before publication.
30
+ * @param input - complete role, content, and source for a new message.
31
+ * @returns an immutable message with a fresh stable identity.
32
+ */
33
+ export function createMessage(input) {
34
+ return freezeMessage({
35
+ ...input,
36
+ id: MessageId(crypto.randomUUID()),
37
+ });
38
+ }
39
+ /**
40
+ * Create one identified user-role message and freeze it before publication.
41
+ * @param input - complete content and source for a new user message.
42
+ * @returns an immutable user message with a fresh stable identity.
43
+ */
44
+ export function createUserMessage(input) {
45
+ return createMessage({
46
+ ...input,
47
+ role: 'user',
48
+ });
49
+ }
50
+ /**
51
+ * Create one identified model-produced assistant message and freeze it before publication.
52
+ * @param input - complete content plus the provider, model, and optional replay state for a new assistant message.
53
+ * @returns an immutable assistant message with fixed role/source tags and a fresh stable identity.
54
+ */
55
+ export function createAssistantMessage(input) {
56
+ return createMessage({
57
+ role: 'assistant',
58
+ content: input.content,
59
+ source: {
60
+ kind: 'model',
61
+ ...input.source,
62
+ },
63
+ });
64
+ }
65
+ /**
66
+ * Create and freeze one identified tool-result message.
67
+ * @param input - call identity, raw result blocks, and outcome.
68
+ * @returns an immutable user-role tool-result message.
69
+ */
70
+ export function createToolResultMessage(input) {
71
+ return createUserMessage({
72
+ source: { kind: 'tool', callId: input.callId },
73
+ content: [{
74
+ type: 'tool-result',
75
+ toolCallId: input.callId,
76
+ content: input.content,
77
+ isError: input.isError,
78
+ }],
79
+ });
80
+ }
81
+ /**
82
+ * Whether a stream chunk carries visible model output (the first-token
83
+ * boundary shared by client step timing and the whole-log sessionStats
84
+ * projection). Empty deltas (heartbeats, empty tool-call frames) do not count
85
+ * as a first token.
86
+ * @param chunk - the stream chunk to test.
87
+ * @returns true when the chunk contains a non-empty text/reasoning/tool delta.
88
+ */
89
+ export function isTokenDelta(chunk) {
90
+ switch (chunk.type) {
91
+ case 'text-delta':
92
+ case 'reasoning-delta':
93
+ return chunk.text !== '';
94
+ case 'tool-call-delta':
95
+ return chunk.argumentsDelta !== '' || chunk.name !== undefined;
96
+ default:
97
+ return false;
98
+ }
99
+ }
100
+ //# sourceMappingURL=message.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
3
+ * new variant fails compilation at every required handler. Do not use it for declaration-merged
4
+ * unions such as session events or content blocks: handle known variants and explicitly fall
5
+ * through because plugins may add valid unknown cases.
6
+ * @module @stackstackstack/dsh-llm/never
7
+ */
8
+ /**
9
+ * Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
10
+ * a value that escaped its type throws with diagnostics at runtime.
11
+ * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
12
+ * @param context - optional label (e.g. the switch site) prefixed into the throw message.
13
+ * @returns never — it always throws, with the offending value JSON-rendered in the message.
14
+ */
15
+ export declare function assertNever(value: never, context?: string): never;
16
+ //# sourceMappingURL=never.d.ts.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
3
+ * new variant fails compilation at every required handler. Do not use it for declaration-merged
4
+ * unions such as session events or content blocks: handle known variants and explicitly fall
5
+ * through because plugins may add valid unknown cases.
6
+ * @module @stackstackstack/dsh-llm/never
7
+ */
8
+ /**
9
+ * Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
10
+ * a value that escaped its type throws with diagnostics at runtime.
11
+ * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
12
+ * @param context - optional label (e.g. the switch site) prefixed into the throw message.
13
+ * @returns never — it always throws, with the offending value JSON-rendered in the message.
14
+ */
15
+ export function assertNever(value, context) {
16
+ // JSON.stringify is typed string but returns undefined for undefined input;
17
+ // String() covers that and other non-serializable escapes.
18
+ const rendered = JSON.stringify(value) ?? String(value);
19
+ throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`);
20
+ }
21
+ //# sourceMappingURL=never.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Provider-owned request-retry policy configuration and resolution.
3
+ *
4
+ * Adapters expose one resolved policy per registered provider route; the
5
+ * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
6
+ *
7
+ * @module @stackstackstack/dsh-llm/retry-policy
8
+ */
9
+ import z from '@deepseek-ai/schemastery';
10
+ /** Bounded exponential backoff with symmetric jitter around each local delay. */
11
+ export interface BackoffConfig {
12
+ /** Initial local exponential-backoff delay in milliseconds (default 500). */
13
+ initialDelayMs?: number;
14
+ /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */
15
+ maxDelayMs?: number;
16
+ /** Symmetric random multiplier range around one (default 0.1). */
17
+ jitterRatio?: number;
18
+ }
19
+ /** Current bounded transient retry behavior for one provider route. */
20
+ export interface NormalRetryPolicyConfig {
21
+ /** Retry only configured transient failure codes. */
22
+ mode: 'normal';
23
+ /** Maximum eligible retries after the first request (default 2). */
24
+ maxRetries?: number;
25
+ /** Stable failure codes eligible for this policy. */
26
+ retryableCodes?: string[];
27
+ /** Local exponential-backoff and jitter configuration. */
28
+ backoff?: BackoffConfig;
29
+ }
30
+ /** Unbounded retry behavior for every model-request failure on one provider route. */
31
+ export interface AlwaysRetryPolicyConfig {
32
+ /** Retry every model-request failure until success, cancellation, or disposal. */
33
+ mode: 'always';
34
+ /** Local exponential-backoff and jitter configuration. */
35
+ backoff?: BackoffConfig;
36
+ }
37
+ /** Provider-owned model-request retry policy configuration. */
38
+ export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig;
39
+ /** Fully resolved backoff shared by both retry modes. */
40
+ export interface ResolvedRetryBackoff {
41
+ readonly initialDelayMs: number;
42
+ readonly maxDelayMs: number;
43
+ readonly jitterRatio: number;
44
+ }
45
+ /** Fully resolved bounded transient retry policy. */
46
+ export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {
47
+ readonly mode: 'normal';
48
+ readonly maxRetries: number;
49
+ readonly retryableCodes: readonly string[];
50
+ }
51
+ /** Fully resolved unbounded retry policy. */
52
+ export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {
53
+ readonly mode: 'always';
54
+ }
55
+ /** Immutable provider policy captured when its adapter route is registered. */
56
+ export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy;
57
+ /** Cordis schema embedded by each concrete provider configuration. */
58
+ export declare const RetryPolicySchema: z<RetryPolicyConfig>;
59
+ /**
60
+ * Validate, default, and detach one provider-owned retry policy.
61
+ * @param config - optional provider configuration; omission selects normal defaults.
62
+ * @param path - diagnostic path naming the provider config that owns the value.
63
+ * @returns an immutable policy safe to capture in provider registration state.
64
+ */
65
+ export declare function resolveRetryPolicy(config: RetryPolicyConfig | undefined, path: string): ResolvedRetryPolicy;
66
+ //# sourceMappingURL=retry-policy.d.ts.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Provider-owned request-retry policy configuration and resolution.
3
+ *
4
+ * Adapters expose one resolved policy per registered provider route; the
5
+ * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
6
+ *
7
+ * @module @stackstackstack/dsh-llm/retry-policy
8
+ */
9
+ import z from '@deepseek-ai/schemastery';
10
+ import { MAX_TIMER_DELAY_MS } from '@stackstackstack/dsh-timeout';
11
+ import { EMPTY_RESPONSE_CODE } from "./error.js";
12
+ const DEFAULT_MAX_RETRIES = 2;
13
+ const DEFAULT_INITIAL_DELAY_MS = 500;
14
+ const DEFAULT_MAX_DELAY_MS = 10_000;
15
+ const DEFAULT_JITTER_RATIO = 0.1;
16
+ const DEFAULT_RETRYABLE_CODES = Object.freeze([
17
+ EMPTY_RESPONSE_CODE,
18
+ 'RATE_LIMIT',
19
+ 'SERVER',
20
+ 'TIMEOUT',
21
+ 'TRANSPORT',
22
+ ]);
23
+ const backoffSchema = z.object({
24
+ initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
25
+ maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
26
+ jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
27
+ });
28
+ const normalPolicySchema = z.object({
29
+ mode: z.const('normal').required(),
30
+ maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
31
+ retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
32
+ backoff: backoffSchema,
33
+ });
34
+ const alwaysPolicySchema = z.object({
35
+ mode: z.const('always').required(),
36
+ backoff: backoffSchema,
37
+ });
38
+ /** Cordis schema embedded by each concrete provider configuration. */
39
+ export const RetryPolicySchema = z.union([
40
+ normalPolicySchema,
41
+ alwaysPolicySchema,
42
+ ]);
43
+ const NORMAL_POLICY_KEYS = new Set([
44
+ 'mode', 'maxRetries', 'retryableCodes', 'backoff',
45
+ ]);
46
+ const ALWAYS_POLICY_KEYS = new Set(['mode', 'backoff']);
47
+ const BACKOFF_KEYS = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']);
48
+ function validateKeys(value, allowed, path) {
49
+ for (const key of Object.keys(value)) {
50
+ if (!allowed.has(key))
51
+ throw new Error(`${path}: unknown key "${key}"`);
52
+ }
53
+ }
54
+ function resolveBackoff(config, path) {
55
+ if (config !== undefined)
56
+ validateKeys(config, BACKOFF_KEYS, path);
57
+ const initialDelayMs = config?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
58
+ const maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
59
+ const jitterRatio = config?.jitterRatio ?? DEFAULT_JITTER_RATIO;
60
+ if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
61
+ throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
62
+ }
63
+ if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
64
+ throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
65
+ }
66
+ if (initialDelayMs > maxDelayMs) {
67
+ throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
68
+ }
69
+ if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
70
+ throw new Error(`${path}.jitterRatio must be between 0 and 1`);
71
+ }
72
+ return Object.freeze({ initialDelayMs, maxDelayMs, jitterRatio });
73
+ }
74
+ /**
75
+ * Validate, default, and detach one provider-owned retry policy.
76
+ * @param config - optional provider configuration; omission selects normal defaults.
77
+ * @param path - diagnostic path naming the provider config that owns the value.
78
+ * @returns an immutable policy safe to capture in provider registration state.
79
+ */
80
+ export function resolveRetryPolicy(config, path) {
81
+ if (config === undefined) {
82
+ return Object.freeze({
83
+ mode: 'normal',
84
+ maxRetries: DEFAULT_MAX_RETRIES,
85
+ retryableCodes: DEFAULT_RETRYABLE_CODES,
86
+ ...resolveBackoff(undefined, `${path}.backoff`),
87
+ });
88
+ }
89
+ switch (config.mode) {
90
+ case 'normal': {
91
+ validateKeys(config, NORMAL_POLICY_KEYS, path);
92
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
93
+ const retryableCodes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES];
94
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) {
95
+ throw new Error(`${path}.maxRetries must be a non-negative safe integer`);
96
+ }
97
+ if (retryableCodes.length === 0) {
98
+ throw new Error(`${path}.retryableCodes must not be empty`);
99
+ }
100
+ if (retryableCodes.some(code => typeof code !== 'string' || code.length === 0)) {
101
+ throw new Error(`${path}.retryableCodes must contain only non-empty strings`);
102
+ }
103
+ if (new Set(retryableCodes).size !== retryableCodes.length) {
104
+ throw new Error(`${path}.retryableCodes must not contain duplicates`);
105
+ }
106
+ return Object.freeze({
107
+ mode: 'normal',
108
+ maxRetries,
109
+ retryableCodes: Object.freeze([...retryableCodes]),
110
+ ...resolveBackoff(config.backoff, `${path}.backoff`),
111
+ });
112
+ }
113
+ case 'always':
114
+ validateKeys(config, ALWAYS_POLICY_KEYS, path);
115
+ return Object.freeze({
116
+ mode: 'always',
117
+ ...resolveBackoff(config.backoff, `${path}.backoff`),
118
+ });
119
+ default:
120
+ throw new Error(`${path}.mode must be "normal" or "always"`);
121
+ }
122
+ }
123
+ //# sourceMappingURL=retry-policy.js.map