@fungi.computer/mule 0.1.1 → 0.2.1
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/dist/contract.d.ts +8 -0
- package/dist/effect.d.ts +1 -7
- package/dist/effect.js +141 -28
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/provider-status.d.ts +12 -2
- package/dist/provider-status.js +117 -14
- package/dist/types.d.ts +2 -0
- package/docs/api/effect.md +14 -12
- package/docs/api/index.md +50 -1
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -154,6 +154,12 @@ export type MuleRunSettings = {
|
|
|
154
154
|
readonly streamOptions?: Omit<SimpleStreamOptions, "signal" | "maxRetries"> | undefined;
|
|
155
155
|
/** Maximum silence between provider stream events, in milliseconds. */
|
|
156
156
|
readonly modelStreamIdleTimeoutMs?: number | undefined;
|
|
157
|
+
/** Trusted per-attempt safety limits may only tighten Mule's defaults. */
|
|
158
|
+
readonly modelStreamLimits?: Partial<{
|
|
159
|
+
firstEventMs: number;
|
|
160
|
+
attemptMs: number;
|
|
161
|
+
outputBytes: number;
|
|
162
|
+
}> | undefined;
|
|
157
163
|
readonly toolExecution?: "parallel" | "sequential" | undefined;
|
|
158
164
|
/**
|
|
159
165
|
* Bounded retry around each assistant call, using Pi's exponential delay.
|
|
@@ -200,4 +206,6 @@ export type MuleRunOutcome = {
|
|
|
200
206
|
readonly terminal: MuleIdentity;
|
|
201
207
|
readonly message: AssistantMessage;
|
|
202
208
|
readonly messages: readonly Message[];
|
|
209
|
+
/** Mule-owned limit diagnostic, not untrusted provider prose. */
|
|
210
|
+
readonly modelLimitReason?: string;
|
|
203
211
|
};
|
package/dist/effect.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { decodeProviderData } from "./provider-data.js";
|
|
2
2
|
export { streamPi as streamModel } from "./provider.js";
|
|
3
|
-
export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
|
|
3
|
+
export { isRetryableProviderRejection, observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
|
|
4
4
|
export { normalizeModel } from "./provider-model.js";
|
|
5
5
|
import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "./types.js";
|
|
6
6
|
import * as Effect from "effect/Effect";
|
|
@@ -16,12 +16,6 @@ export type { MuleRetryObservation } from "./contract.js";
|
|
|
16
16
|
* a host that wants Pi coding-agent's numbers passes this explicitly.
|
|
17
17
|
*/
|
|
18
18
|
export declare const piCodingAgentRetryDefaults: RetryPolicy;
|
|
19
|
-
/**
|
|
20
|
-
* Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
|
|
21
|
-
* `settings-manager.js`). A host that wants Pi's number for
|
|
22
|
-
* `modelStreamIdleTimeoutMs` passes this explicitly.
|
|
23
|
-
*/
|
|
24
|
-
export declare const piHttpIdleTimeoutMs = 300000;
|
|
25
19
|
/** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
|
|
26
20
|
export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
|
|
27
21
|
export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
|
package/dist/effect.js
CHANGED
|
@@ -2,7 +2,7 @@ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
|
|
|
2
2
|
export { decodeProviderData } from "./provider-data.js";
|
|
3
3
|
export { streamPi as streamModel } from "./provider.js";
|
|
4
4
|
import { nanoid } from "@fungi.computer/cairn";
|
|
5
|
-
export { observeProviderStatus, } from "./provider-status.js";
|
|
5
|
+
export { isRetryableProviderRejection, observeProviderStatus, } from "./provider-status.js";
|
|
6
6
|
// Pi's root entry also evaluates its TypeBox validator compiler and model
|
|
7
7
|
// stores; Mule needs only these leaf helpers, so it imports them directly and
|
|
8
8
|
// keeps that work out of every host's startup.
|
|
@@ -30,18 +30,20 @@ export const piCodingAgentRetryDefaults = {
|
|
|
30
30
|
baseDelayMs: 2000,
|
|
31
31
|
maxAgentDelayMs: 60_000,
|
|
32
32
|
};
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
/** Starting safety limits for one provider attempt; hosts may tighten them. Not public: the values are starting points, not a contract. */
|
|
34
|
+
const modelStreamLimits = {
|
|
35
|
+
firstEventMs: 60_000,
|
|
36
|
+
idleMs: 120_000,
|
|
37
|
+
attemptMs: 600_000,
|
|
38
|
+
outputBytes: 2 * 1024 * 1024,
|
|
39
|
+
};
|
|
39
40
|
/**
|
|
40
41
|
* Grace period a release waits for a provider iterator that ignores abort,
|
|
41
42
|
* so a timed-out run's scope close never hangs. This is a Mule transport
|
|
42
43
|
* detail with no Pi equivalent, so it stays a constant, not host config.
|
|
43
44
|
*/
|
|
44
45
|
const providerIteratorReleaseGraceMs = 1_000;
|
|
46
|
+
const modelLimitCause = Symbol("Mule model limit");
|
|
45
47
|
/** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
|
|
46
48
|
export function isContextOverflow(message, contextWindow) {
|
|
47
49
|
return piIsContextOverflow(message, contextWindow);
|
|
@@ -235,17 +237,47 @@ function pullPiEvent(iterator, controller) {
|
|
|
235
237
|
catch: (cause) => failure("model", "Pi model stream failed", cause),
|
|
236
238
|
});
|
|
237
239
|
}
|
|
238
|
-
function withModelStreamDeadline(effect, timeoutMs) {
|
|
239
|
-
if (timeoutMs === undefined)
|
|
240
|
-
return effect;
|
|
240
|
+
function withModelStreamDeadline(effect, timeoutMs, reason) {
|
|
241
241
|
return effect.pipe(Effect.timeoutOrElse({
|
|
242
242
|
duration: timeoutMs,
|
|
243
|
-
orElse: () => Effect.fail(failure("model",
|
|
243
|
+
orElse: () => Effect.fail(failure("model", reason(), modelLimitCause)),
|
|
244
244
|
}));
|
|
245
245
|
}
|
|
246
246
|
function streamTurn(input, context, run, turn, emit, attempt) {
|
|
247
247
|
const messageIdentity = identity("message", turn);
|
|
248
|
+
let partial;
|
|
249
|
+
const streamState = { started: false, terminal: undefined };
|
|
248
250
|
return Effect.scoped(Effect.gen(function* () {
|
|
251
|
+
const limits = input.modelStreamLimits;
|
|
252
|
+
const firstEventMs = Math.min(limits?.firstEventMs ?? modelStreamLimits.firstEventMs, modelStreamLimits.firstEventMs);
|
|
253
|
+
const attemptMs = Math.min(limits?.attemptMs ?? modelStreamLimits.attemptMs, modelStreamLimits.attemptMs);
|
|
254
|
+
const outputLimit = Math.min(limits?.outputBytes ?? modelStreamLimits.outputBytes, modelStreamLimits.outputBytes);
|
|
255
|
+
if ([
|
|
256
|
+
firstEventMs,
|
|
257
|
+
attemptMs,
|
|
258
|
+
outputLimit,
|
|
259
|
+
input.modelStreamIdleTimeoutMs ?? 1,
|
|
260
|
+
].some((value) => !Number.isSafeInteger(value) || value <= 0))
|
|
261
|
+
return yield* Effect.fail(failure("model", "Model stream limits must be positive safe integers"));
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
let lastEventAt = startedAt;
|
|
264
|
+
let maxGapMs = 0;
|
|
265
|
+
let outputBytes = 0;
|
|
266
|
+
const encoder = new TextEncoder();
|
|
267
|
+
let hasEvent = false;
|
|
268
|
+
const limitError = (kind) => failure("model", `Model ${kind} after ${String(Date.now() - startedAt)} ms (max provider gap ${String(Math.max(maxGapMs, Date.now() - lastEventAt))} ms, streamed ${String(outputBytes)} bytes)`, modelLimitCause);
|
|
269
|
+
const deadline = (effect) => {
|
|
270
|
+
const elapsed = Date.now() - startedAt;
|
|
271
|
+
const wholeRemaining = attemptMs - elapsed;
|
|
272
|
+
const gapLimit = Math.min(hasEvent ? modelStreamLimits.idleMs : firstEventMs, input.modelStreamIdleTimeoutMs ?? modelStreamLimits.idleMs);
|
|
273
|
+
const gapRemaining = gapLimit - (Date.now() - lastEventAt);
|
|
274
|
+
const kind = wholeRemaining <= gapRemaining
|
|
275
|
+
? "attempt timed out"
|
|
276
|
+
: hasEvent
|
|
277
|
+
? "stream timed out (silence)"
|
|
278
|
+
: "first event timed out";
|
|
279
|
+
return withModelStreamDeadline(effect, Math.max(0, Math.min(wholeRemaining, gapRemaining)), () => limitError(kind).message);
|
|
280
|
+
};
|
|
249
281
|
const controller = new AbortController();
|
|
250
282
|
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
251
283
|
controller.abort("Mule model scope closed");
|
|
@@ -273,17 +305,13 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
273
305
|
const descriptors = (input.tools ?? []).map((tool) => tool.descriptor);
|
|
274
306
|
if (descriptors.length > 0)
|
|
275
307
|
piContext.tools = descriptors;
|
|
276
|
-
const response = yield*
|
|
308
|
+
const response = yield* deadline(Effect.tryPromise({
|
|
277
309
|
try: async () => {
|
|
278
310
|
const stream = input.stream ?? streamPi;
|
|
279
311
|
return stream(input.model, normalizeContext(piContext), options);
|
|
280
312
|
},
|
|
281
313
|
catch: (cause) => failure("model", "Pi model stream could not start", cause),
|
|
282
|
-
})
|
|
283
|
-
const streamState = {
|
|
284
|
-
started: false,
|
|
285
|
-
terminal: undefined,
|
|
286
|
-
};
|
|
314
|
+
}));
|
|
287
315
|
const iterator = yield* Effect.acquireRelease(Effect.sync(() => response[Symbol.asyncIterator]()), (active) => Effect.tryPromise({
|
|
288
316
|
try: () => {
|
|
289
317
|
controller.abort("Mule model stream closed");
|
|
@@ -303,6 +331,19 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
303
331
|
catch: (cause) => failure("model", "Pi model stream failed", cause),
|
|
304
332
|
}).pipe(Effect.ignore));
|
|
305
333
|
const observeEvent = (event) => {
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
maxGapMs = Math.max(maxGapMs, now - lastEventAt);
|
|
336
|
+
lastEventAt = now;
|
|
337
|
+
hasEvent = true;
|
|
338
|
+
if (event.type === "text_delta" ||
|
|
339
|
+
event.type === "thinking_delta" ||
|
|
340
|
+
event.type === "toolcall_delta") {
|
|
341
|
+
outputBytes += encoder.encode(event.delta).byteLength;
|
|
342
|
+
if (outputBytes > outputLimit)
|
|
343
|
+
return Effect.fail(limitError("output limit exceeded"));
|
|
344
|
+
}
|
|
345
|
+
if ("partial" in event)
|
|
346
|
+
partial = event.partial;
|
|
306
347
|
switch (event.type) {
|
|
307
348
|
case "start": {
|
|
308
349
|
context.push(event.partial);
|
|
@@ -345,17 +386,17 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
345
386
|
}
|
|
346
387
|
}
|
|
347
388
|
};
|
|
348
|
-
const pull = () =>
|
|
389
|
+
const pull = () => deadline(pullPiEvent(iterator, controller));
|
|
349
390
|
let next = yield* pull();
|
|
350
391
|
while (!next.done) {
|
|
351
392
|
yield* observeEvent(next.value);
|
|
352
393
|
next = yield* pull();
|
|
353
394
|
}
|
|
354
395
|
const completed = streamState.terminal ??
|
|
355
|
-
(yield*
|
|
396
|
+
(yield* deadline(Effect.tryPromise({
|
|
356
397
|
try: () => response.result(),
|
|
357
398
|
catch: (cause) => failure("model", "Pi model stream did not settle", cause),
|
|
358
|
-
})
|
|
399
|
+
})));
|
|
359
400
|
if (streamState.started)
|
|
360
401
|
context[context.length - 1] = completed;
|
|
361
402
|
else {
|
|
@@ -376,7 +417,62 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
376
417
|
value: completed,
|
|
377
418
|
});
|
|
378
419
|
return { identity: messageIdentity, message: completed };
|
|
379
|
-
}))
|
|
420
|
+
}).pipe(Effect.catch((error) => {
|
|
421
|
+
if (error.cause !== modelLimitCause)
|
|
422
|
+
return Effect.fail(error);
|
|
423
|
+
const value = {
|
|
424
|
+
role: "assistant",
|
|
425
|
+
content: partial?.content ?? [],
|
|
426
|
+
api: input.model.api,
|
|
427
|
+
provider: input.model.provider,
|
|
428
|
+
model: input.model.id,
|
|
429
|
+
usage: partial?.usage ?? {
|
|
430
|
+
input: 0,
|
|
431
|
+
output: 0,
|
|
432
|
+
cacheRead: 0,
|
|
433
|
+
cacheWrite: 0,
|
|
434
|
+
totalTokens: 0,
|
|
435
|
+
cost: {
|
|
436
|
+
input: 0,
|
|
437
|
+
output: 0,
|
|
438
|
+
cacheRead: 0,
|
|
439
|
+
cacheWrite: 0,
|
|
440
|
+
total: 0,
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
stopReason: "error",
|
|
444
|
+
timestamp: Date.now(),
|
|
445
|
+
errorMessage: input.retry?.enabled && attempt > input.retry.maxRetries
|
|
446
|
+
? `The model failed ${String(attempt)} times on this conversation: ${error.message}`
|
|
447
|
+
: error.message,
|
|
448
|
+
};
|
|
449
|
+
return Effect.gen(function* () {
|
|
450
|
+
if (!streamState.started) {
|
|
451
|
+
context.push(value);
|
|
452
|
+
yield* emit({
|
|
453
|
+
type: "message_start",
|
|
454
|
+
run,
|
|
455
|
+
turn,
|
|
456
|
+
message: messageIdentity,
|
|
457
|
+
value,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
else
|
|
461
|
+
context[context.length - 1] = value;
|
|
462
|
+
yield* emit({
|
|
463
|
+
type: "message_end",
|
|
464
|
+
run,
|
|
465
|
+
turn,
|
|
466
|
+
message: messageIdentity,
|
|
467
|
+
value,
|
|
468
|
+
});
|
|
469
|
+
return {
|
|
470
|
+
identity: messageIdentity,
|
|
471
|
+
message: value,
|
|
472
|
+
limitReached: true,
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
})));
|
|
380
476
|
}
|
|
381
477
|
/**
|
|
382
478
|
* Retry only the host's typed transient decision, using Pi's maintained delay.
|
|
@@ -430,13 +526,14 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
|
|
|
430
526
|
policy === undefined ||
|
|
431
527
|
!policy.enabled ||
|
|
432
528
|
retries >= policy.maxRetries) {
|
|
433
|
-
if (retries > 0)
|
|
529
|
+
if (retries > 0) {
|
|
434
530
|
yield* finish(message.stopReason !== "error" &&
|
|
435
531
|
message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
|
|
532
|
+
}
|
|
436
533
|
return latest;
|
|
437
534
|
}
|
|
438
535
|
const retryable = yield* Effect.try({
|
|
439
|
-
try: () => input.retryable(message),
|
|
536
|
+
try: () => latest?.limitReached === true || input.retryable(message),
|
|
440
537
|
catch: (cause) => failure("model", "Typed model retry decision failed", cause),
|
|
441
538
|
});
|
|
442
539
|
if (!retryable) {
|
|
@@ -450,12 +547,16 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
|
|
|
450
547
|
try: () => input.retryAfterMs?.(message),
|
|
451
548
|
catch: (cause) => failure("model", "Model retry delay observation failed", cause),
|
|
452
549
|
});
|
|
453
|
-
const
|
|
550
|
+
const minimumDelayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
|
|
454
551
|
Number.isSafeInteger(after) &&
|
|
455
552
|
after >= 0 &&
|
|
456
553
|
after <= 60_000
|
|
457
554
|
? after
|
|
458
555
|
: 0);
|
|
556
|
+
// Add rather than subtract jitter: a provider's Retry-After is a floor.
|
|
557
|
+
// Jitter stops at one minute; a longer explicit host policy stays intact.
|
|
558
|
+
const jitterMs = Math.min(1_000, minimumDelayMs / 4, Math.max(0, 60_000 - minimumDelayMs));
|
|
559
|
+
const delayMs = minimumDelayMs + Math.floor(Math.random() * jitterMs);
|
|
459
560
|
yield* reportRetry({
|
|
460
561
|
type: "retry_scheduled",
|
|
461
562
|
run,
|
|
@@ -529,11 +630,14 @@ function completeLoopTurn(input, context, newMessages, run, turn, pending, emit)
|
|
|
529
630
|
message: streamed.message,
|
|
530
631
|
toolResults: batch.messages,
|
|
531
632
|
});
|
|
532
|
-
|
|
633
|
+
const completed = {
|
|
533
634
|
message: streamed.message,
|
|
534
635
|
toolResults: batch.messages,
|
|
535
636
|
terminate: batch.terminate,
|
|
536
637
|
};
|
|
638
|
+
if (streamed.limitReached && streamed.message.errorMessage !== undefined)
|
|
639
|
+
return { ...completed, modelLimitReason: streamed.message.errorMessage };
|
|
640
|
+
return completed;
|
|
537
641
|
});
|
|
538
642
|
}
|
|
539
643
|
/** Resolve only missing results. A stored and current safe policy are both required. */
|
|
@@ -630,10 +734,19 @@ function selectNextTurn(input, turn, context, newMessages) {
|
|
|
630
734
|
return { continue: followUps.length > 0, pending: followUps };
|
|
631
735
|
});
|
|
632
736
|
}
|
|
633
|
-
function finishRun(run, terminal, message, messages, status, emit) {
|
|
737
|
+
function finishRun(run, terminal, message, messages, status, emit, modelLimitReason) {
|
|
634
738
|
return Effect.gen(function* () {
|
|
635
739
|
yield* emit({ type: "run_end", run, terminal, outcome: status });
|
|
636
|
-
|
|
740
|
+
const outcome = {
|
|
741
|
+
status,
|
|
742
|
+
run,
|
|
743
|
+
terminal,
|
|
744
|
+
message,
|
|
745
|
+
messages,
|
|
746
|
+
};
|
|
747
|
+
if (modelLimitReason !== undefined)
|
|
748
|
+
return { ...outcome, modelLimitReason };
|
|
749
|
+
return outcome;
|
|
637
750
|
});
|
|
638
751
|
}
|
|
639
752
|
/** Run Pi's model/tool loop inside Effect without changing Pi's values. */
|
|
@@ -702,7 +815,7 @@ function runLoop(input, emit, runIdentity, terminalIdentity) {
|
|
|
702
815
|
finalMessage = completed.message;
|
|
703
816
|
const status = statusFor(completed.message);
|
|
704
817
|
if (status !== "completed") {
|
|
705
|
-
return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit);
|
|
818
|
+
return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit, completed.modelLimitReason);
|
|
706
819
|
}
|
|
707
820
|
// Tool results always continue the run: this is where Pi checks its
|
|
708
821
|
// context before the next assistant response (Pi 0.84.4+).
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { decodeProviderData } from "./provider-data.js";
|
|
|
2
2
|
export { streamPi as streamModel } from "./provider.js";
|
|
3
3
|
export { normalizeModel } from "./provider-model.js";
|
|
4
4
|
import type { Message, Tool, ToolCall } from "./types.js";
|
|
5
|
-
export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
|
|
5
|
+
export { isRetryableProviderRejection, observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
|
|
6
6
|
import type { MuleCompletedTurn, MuleEvent, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
|
|
7
7
|
export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
|
|
8
8
|
/** Promise listener adapted once into Mule's canonical Effect program. */
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { decodeProviderData } from "./provider-data.js";
|
|
2
2
|
export { streamPi as streamModel } from "./provider.js";
|
|
3
3
|
export { normalizeModel } from "./provider-model.js";
|
|
4
|
-
export { observeProviderStatus, } from "./provider-status.js";
|
|
4
|
+
export { isRetryableProviderRejection, observeProviderStatus, } from "./provider-status.js";
|
|
5
5
|
import * as Cause from "effect/Cause";
|
|
6
6
|
import * as Effect from "effect/Effect";
|
|
7
7
|
import * as Exit from "effect/Exit";
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import type { SimpleStreamOptions } from "./types.js";
|
|
1
|
+
import type { AssistantMessage, SimpleStreamOptions } from "./types.js";
|
|
2
2
|
/** Bounded provider evidence, never response bodies, headers or error prose. */
|
|
3
3
|
export type ProviderRejection = Readonly<{
|
|
4
4
|
code: "usage-limit-reached";
|
|
5
|
+
/** Provider-declared epoch milliseconds, absent when unknown. */
|
|
6
|
+
resetAt?: number;
|
|
7
|
+
limit?: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet";
|
|
8
|
+
}> | Readonly<{
|
|
9
|
+
code: "rate-limit";
|
|
10
|
+
retryAfterMs?: number;
|
|
11
|
+
}> | Readonly<{
|
|
12
|
+
code: "overloaded";
|
|
5
13
|
}> | Readonly<{
|
|
6
14
|
code: "provider_unavailable";
|
|
7
15
|
status?: number;
|
|
@@ -9,6 +17,8 @@ export type ProviderRejection = Readonly<{
|
|
|
9
17
|
code: "provider_rejected";
|
|
10
18
|
status: number;
|
|
11
19
|
}>;
|
|
20
|
+
/** Retry only typed transient failures, never usage exhaustion or rejection. */
|
|
21
|
+
export declare function isRetryableProviderRejection(rejection: ProviderRejection | undefined): boolean;
|
|
12
22
|
/** One provider call's transport and parsed-event observations. */
|
|
13
23
|
export type ProviderStatusRecorder = Readonly<{
|
|
14
24
|
/** Request-local fetch observer, retaining the supplied transport. */
|
|
@@ -16,7 +26,7 @@ export type ProviderStatusRecorder = Readonly<{
|
|
|
16
26
|
/** Pi's maintained parsed-event hook, composed with the supplied observer. */
|
|
17
27
|
onProviderStreamEvent: NonNullable<SimpleStreamOptions["onProviderStreamEvent"]>;
|
|
18
28
|
/** Latest typed failure evidence for this call, or no observed failure. */
|
|
19
|
-
rejection: () => ProviderRejection | undefined;
|
|
29
|
+
rejection: (message?: AssistantMessage) => ProviderRejection | undefined;
|
|
20
30
|
}>;
|
|
21
31
|
/**
|
|
22
32
|
* Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
|
package/dist/provider-status.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Option, Schema } from "effect";
|
|
1
|
+
import { DateTime, Option, Schema } from "effect";
|
|
2
2
|
const usageLimitCode = "subscription_sharing_usage_limit_exceeded";
|
|
3
3
|
const providerError = Schema.Struct({
|
|
4
4
|
error: Schema.Struct({
|
|
5
|
-
code: Schema.String,
|
|
5
|
+
code: Schema.optional(Schema.String),
|
|
6
|
+
type: Schema.optional(Schema.String),
|
|
6
7
|
}),
|
|
7
8
|
});
|
|
8
9
|
const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
|
|
@@ -12,13 +13,70 @@ const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
|
|
|
12
13
|
const decodeErrorReply = Schema.decodeUnknownOption(Schema.fromJsonString(providerError));
|
|
13
14
|
const decodeBodyChunk = Schema.decodeUnknownSync(Schema.instanceOf(Uint8Array));
|
|
14
15
|
const errorBodyBytes = 16 * 1024;
|
|
16
|
+
const decodeSeconds = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isBetween({ minimum: 0, maximum: 60 })));
|
|
17
|
+
const decodeReset = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 8_640_000_000_000 })));
|
|
18
|
+
const decodeLimit = Schema.decodeUnknownOption(Schema.Literals([
|
|
19
|
+
"five_hour",
|
|
20
|
+
"seven_day",
|
|
21
|
+
"seven_day_opus",
|
|
22
|
+
"seven_day_sonnet",
|
|
23
|
+
]));
|
|
24
|
+
const decodeRejected = Schema.decodeUnknownOption(Schema.Literal("rejected"));
|
|
25
|
+
const decodeExhausted = Schema.decodeUnknownOption(Schema.Literal("0"));
|
|
26
|
+
const decodeDate = Schema.decodeUnknownOption(Schema.DateTimeUtcFromString);
|
|
27
|
+
const decodeOverloaded = Schema.decodeUnknownOption(Schema.Struct({
|
|
28
|
+
type: Schema.Literal("error"),
|
|
29
|
+
error: Schema.Struct({ type: Schema.Literal("overloaded_error") }),
|
|
30
|
+
}));
|
|
31
|
+
function resetDelay(value, now) {
|
|
32
|
+
const date = decodeDate(value);
|
|
33
|
+
if (Option.isNone(date))
|
|
34
|
+
return undefined;
|
|
35
|
+
const delay = DateTime.toEpochMillis(date.value) - now;
|
|
36
|
+
return delay >= 0 && delay <= 60_000 ? Math.ceil(delay) : undefined;
|
|
37
|
+
}
|
|
38
|
+
function rateLimit(headers) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const header = headers.get("retry-after");
|
|
41
|
+
const seconds = decodeSeconds(header);
|
|
42
|
+
let delay = Option.isSome(seconds)
|
|
43
|
+
? Math.ceil(seconds.value * 1000)
|
|
44
|
+
: resetDelay(header, now);
|
|
45
|
+
// API-key rate windows are not subscription caps. Only an explicitly
|
|
46
|
+
// exhausted dimension supplies a retry floor; missing facts stay unknown.
|
|
47
|
+
for (const dimension of [
|
|
48
|
+
"requests",
|
|
49
|
+
"tokens",
|
|
50
|
+
"input-tokens",
|
|
51
|
+
"output-tokens",
|
|
52
|
+
]) {
|
|
53
|
+
if (Option.isNone(decodeExhausted(headers.get(`anthropic-ratelimit-${dimension}-remaining`))))
|
|
54
|
+
continue;
|
|
55
|
+
const reset = resetDelay(headers.get(`anthropic-ratelimit-${dimension}-reset`), now);
|
|
56
|
+
if (reset !== undefined)
|
|
57
|
+
delay = Math.max(delay ?? 0, reset);
|
|
58
|
+
}
|
|
59
|
+
return delay !== undefined
|
|
60
|
+
? { code: "rate-limit", retryAfterMs: delay }
|
|
61
|
+
: { code: "rate-limit" };
|
|
62
|
+
}
|
|
63
|
+
function usageLimit(headers) {
|
|
64
|
+
const reset = decodeReset(headers.get("anthropic-ratelimit-unified-reset"));
|
|
65
|
+
const limit = decodeLimit(headers.get("anthropic-ratelimit-unified-representative-claim"));
|
|
66
|
+
let result = { code: "usage-limit-reached" };
|
|
67
|
+
if (Option.isSome(reset))
|
|
68
|
+
result = { ...result, resetAt: reset.value * 1000 };
|
|
69
|
+
if (Option.isSome(limit))
|
|
70
|
+
result = { ...result, limit: limit.value };
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
15
73
|
/** Pi's pinned provider-request transient classes, kept as typed evidence. */
|
|
16
74
|
const transientStatus = (status) => status === 408 ||
|
|
17
75
|
status === 409 ||
|
|
18
76
|
status === 429 ||
|
|
19
77
|
(status >= 500 && status <= 599);
|
|
20
78
|
/** Retain Local's bounded error-copy observation; Pi still owns its response. */
|
|
21
|
-
async function
|
|
79
|
+
async function providerErrorKind(response) {
|
|
22
80
|
const body = response.clone().body;
|
|
23
81
|
if (body === null)
|
|
24
82
|
return undefined;
|
|
@@ -39,7 +97,9 @@ async function providerErrorCode(response) {
|
|
|
39
97
|
}
|
|
40
98
|
text += decoder.decode();
|
|
41
99
|
const decoded = decodeErrorReply(text);
|
|
42
|
-
return Option.isSome(decoded)
|
|
100
|
+
return Option.isSome(decoded)
|
|
101
|
+
? (decoded.value.error.code ?? decoded.value.error.type)
|
|
102
|
+
: undefined;
|
|
43
103
|
}
|
|
44
104
|
finally {
|
|
45
105
|
// A tee's cancellation may await the original consumer. Do not hold Pi's
|
|
@@ -50,6 +110,20 @@ async function providerErrorCode(response) {
|
|
|
50
110
|
reader.releaseLock();
|
|
51
111
|
}
|
|
52
112
|
}
|
|
113
|
+
/** Retry only typed transient failures, never usage exhaustion or rejection. */
|
|
114
|
+
export function isRetryableProviderRejection(rejection) {
|
|
115
|
+
if (rejection === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
switch (rejection.code) {
|
|
118
|
+
case "provider_unavailable":
|
|
119
|
+
case "rate-limit":
|
|
120
|
+
case "overloaded":
|
|
121
|
+
return true;
|
|
122
|
+
case "usage-limit-reached":
|
|
123
|
+
case "provider_rejected":
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
53
127
|
/**
|
|
54
128
|
* Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
|
|
55
129
|
* decoding; this module retains no provider prose.
|
|
@@ -69,15 +143,25 @@ export function observeProviderStatus(options) {
|
|
|
69
143
|
throw cause;
|
|
70
144
|
}
|
|
71
145
|
const status = response.status;
|
|
72
|
-
const code = status === 429
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
146
|
+
const code = status === 429 || status === 529
|
|
147
|
+
? await providerErrorKind(response)
|
|
148
|
+
: undefined;
|
|
149
|
+
const subscriptionRejected = status === 429 &&
|
|
150
|
+
Option.isSome(decodeRejected(response.headers.get("anthropic-ratelimit-unified-status")));
|
|
151
|
+
if (status < 400 || status > 599)
|
|
152
|
+
rejection = undefined;
|
|
153
|
+
else if (subscriptionRejected)
|
|
154
|
+
rejection = usageLimit(response.headers);
|
|
155
|
+
else if (code === usageLimitCode)
|
|
156
|
+
rejection = { code: "usage-limit-reached" };
|
|
157
|
+
else if (code === "overloaded_error")
|
|
158
|
+
rejection = { code: "overloaded" };
|
|
159
|
+
else if (status === 429 && code !== "insufficient_quota")
|
|
160
|
+
rejection = rateLimit(response.headers);
|
|
161
|
+
else if (transientStatus(status) && code !== "insufficient_quota")
|
|
162
|
+
rejection = { code: "provider_unavailable", status };
|
|
163
|
+
else
|
|
164
|
+
rejection = { code: "provider_rejected", status };
|
|
81
165
|
return response;
|
|
82
166
|
};
|
|
83
167
|
return {
|
|
@@ -87,8 +171,27 @@ export function observeProviderStatus(options) {
|
|
|
87
171
|
if (Option.isSome(failed) &&
|
|
88
172
|
failed.value.response.error.code === usageLimitCode)
|
|
89
173
|
rejection = { code: "usage-limit-reached" };
|
|
174
|
+
if (Option.isSome(decodeOverloaded(data)))
|
|
175
|
+
rejection = { code: "overloaded" };
|
|
90
176
|
await options?.onProviderStreamEvent?.(data, model);
|
|
91
177
|
},
|
|
92
|
-
rejection: () =>
|
|
178
|
+
rejection: (message) => {
|
|
179
|
+
if (rejection !== undefined)
|
|
180
|
+
return rejection;
|
|
181
|
+
// Pi throws Anthropic event:error before its parsed-event hook and
|
|
182
|
+
// retains that JSON envelope verbatim as the terminal errorMessage.
|
|
183
|
+
// Decode that maintained producer's representation, never match prose.
|
|
184
|
+
const text = message?.errorMessage;
|
|
185
|
+
if (message?.stopReason !== "error" ||
|
|
186
|
+
text === undefined ||
|
|
187
|
+
text.length > errorBodyBytes ||
|
|
188
|
+
new TextEncoder().encode(text).byteLength > errorBodyBytes)
|
|
189
|
+
return undefined;
|
|
190
|
+
const decoded = decodeErrorReply(text);
|
|
191
|
+
return Option.isSome(decoded) &&
|
|
192
|
+
decoded.value.error.type === "overloaded_error"
|
|
193
|
+
? { code: "overloaded" }
|
|
194
|
+
: undefined;
|
|
195
|
+
},
|
|
93
196
|
};
|
|
94
197
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -177,6 +177,8 @@ export type AssistantMessage = {
|
|
|
177
177
|
stopReason: StopReason;
|
|
178
178
|
deferred?: DeferredHandle;
|
|
179
179
|
errorMessage?: string;
|
|
180
|
+
/** Provider prose for the failed conversation turn, never a diagnostic. */
|
|
181
|
+
providerError?: string;
|
|
180
182
|
rawStopReason?: string;
|
|
181
183
|
endTurn?: boolean;
|
|
182
184
|
timestamp: number;
|
package/docs/api/effect.md
CHANGED
|
@@ -1091,6 +1091,14 @@ optional deferred?: DeferredHandle;
|
|
|
1091
1091
|
optional errorMessage?: string;
|
|
1092
1092
|
```
|
|
1093
1093
|
|
|
1094
|
+
##### providerError?
|
|
1095
|
+
|
|
1096
|
+
```ts
|
|
1097
|
+
optional providerError?: string;
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Provider prose for the failed conversation turn, never a diagnostic.
|
|
1101
|
+
|
|
1094
1102
|
##### rawStopReason?
|
|
1095
1103
|
|
|
1096
1104
|
```ts
|
|
@@ -1847,18 +1855,6 @@ that wants Pi coding-agent's numbers passes this explicitly.
|
|
|
1847
1855
|
|
|
1848
1856
|
---
|
|
1849
1857
|
|
|
1850
|
-
### piHttpIdleTimeoutMs
|
|
1851
|
-
|
|
1852
|
-
```ts
|
|
1853
|
-
const piHttpIdleTimeoutMs: 300000 = 300_000;
|
|
1854
|
-
```
|
|
1855
|
-
|
|
1856
|
-
Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
|
|
1857
|
-
`settings-manager.js`). A host that wants Pi's number for
|
|
1858
|
-
`modelStreamIdleTimeoutMs` passes this explicitly.
|
|
1859
|
-
|
|
1860
|
-
---
|
|
1861
|
-
|
|
1862
1858
|
### muleEffect
|
|
1863
1859
|
|
|
1864
1860
|
```ts
|
|
@@ -1924,6 +1920,12 @@ Re-exports [streamModel](index.md#streammodel)
|
|
|
1924
1920
|
|
|
1925
1921
|
---
|
|
1926
1922
|
|
|
1923
|
+
### isRetryableProviderRejection
|
|
1924
|
+
|
|
1925
|
+
Re-exports [isRetryableProviderRejection](index.md#isretryableproviderrejection)
|
|
1926
|
+
|
|
1927
|
+
---
|
|
1928
|
+
|
|
1927
1929
|
### observeProviderStatus
|
|
1928
1930
|
|
|
1929
1931
|
Re-exports [observeProviderStatus](index.md#observeproviderstatus)
|
package/docs/api/index.md
CHANGED
|
@@ -657,6 +657,18 @@ readonly optional modelStreamIdleTimeoutMs?: number;
|
|
|
657
657
|
|
|
658
658
|
Maximum silence between provider stream events, in milliseconds.
|
|
659
659
|
|
|
660
|
+
##### modelStreamLimits?
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
readonly optional modelStreamLimits?: Partial<{
|
|
664
|
+
firstEventMs: number;
|
|
665
|
+
attemptMs: number;
|
|
666
|
+
outputBytes: number;
|
|
667
|
+
}>;
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Trusted per-attempt safety limits may only tighten Mule's defaults.
|
|
671
|
+
|
|
660
672
|
##### toolExecution?
|
|
661
673
|
|
|
662
674
|
```ts
|
|
@@ -754,6 +766,14 @@ readonly message: AssistantMessage;
|
|
|
754
766
|
readonly messages: readonly Message[];
|
|
755
767
|
```
|
|
756
768
|
|
|
769
|
+
##### modelLimitReason?
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
readonly optional modelLimitReason?: string;
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
Mule-owned limit diagnostic, not untrusted provider prose.
|
|
776
|
+
|
|
757
777
|
---
|
|
758
778
|
|
|
759
779
|
### MulePromiseListener
|
|
@@ -923,6 +943,15 @@ the run, or with the abort reason (an `AbortError` when the reason is not an
|
|
|
923
943
|
type ProviderRejection =
|
|
924
944
|
| Readonly<{
|
|
925
945
|
code: "usage-limit-reached";
|
|
946
|
+
resetAt?: number;
|
|
947
|
+
limit?: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet";
|
|
948
|
+
}>
|
|
949
|
+
| Readonly<{
|
|
950
|
+
code: "rate-limit";
|
|
951
|
+
retryAfterMs?: number;
|
|
952
|
+
}>
|
|
953
|
+
| Readonly<{
|
|
954
|
+
code: "overloaded";
|
|
926
955
|
}>
|
|
927
956
|
| Readonly<{
|
|
928
957
|
code: "provider_unavailable";
|
|
@@ -946,7 +975,7 @@ type ProviderStatusRecorder = Readonly<{
|
|
|
946
975
|
onProviderStreamEvent: NonNullable<
|
|
947
976
|
SimpleStreamOptions["onProviderStreamEvent"]
|
|
948
977
|
>;
|
|
949
|
-
rejection: () => ProviderRejection | undefined;
|
|
978
|
+
rejection: (message?) => ProviderRejection | undefined;
|
|
950
979
|
}>;
|
|
951
980
|
```
|
|
952
981
|
|
|
@@ -1020,6 +1049,26 @@ Validate a catalog model's opaque sampling fields before it enters Mule.
|
|
|
1020
1049
|
|
|
1021
1050
|
---
|
|
1022
1051
|
|
|
1052
|
+
### isRetryableProviderRejection()
|
|
1053
|
+
|
|
1054
|
+
```ts
|
|
1055
|
+
function isRetryableProviderRejection(rejection): boolean;
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
Retry only typed transient failures, never usage exhaustion or rejection.
|
|
1059
|
+
|
|
1060
|
+
#### Parameters
|
|
1061
|
+
|
|
1062
|
+
| Parameter | Type |
|
|
1063
|
+
| ----------- | -------------------------------------------------------- |
|
|
1064
|
+
| `rejection` | [`ProviderRejection`](#providerrejection) \| `undefined` |
|
|
1065
|
+
|
|
1066
|
+
#### Returns
|
|
1067
|
+
|
|
1068
|
+
`boolean`
|
|
1069
|
+
|
|
1070
|
+
---
|
|
1071
|
+
|
|
1023
1072
|
### observeProviderStatus()
|
|
1024
1073
|
|
|
1025
1074
|
```ts
|