@fungi.computer/mule 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,10 @@ npm install @fungi.computer/mule
12
12
  ```
13
13
 
14
14
  The package ships ESM only and depends on `effect` and Pi
15
- (`@earendil-works/pi-ai`) at runtime, plus `@fungi.computer/cairn` for IDs. Mule
16
- does not require or install the MCP SDK. Consumers that type-check dependency
17
- declarations (`skipLibCheck: false`) must install Pi’s Google GenAI optional
18
- declaration dependency themselves:
15
+ (`@earendil-works/pi-ai`) at runtime, plus TypeBox and `@fungi.computer/cairn`
16
+ for IDs. Mule does not require or install the MCP SDK. Consumers that type-check
17
+ dependency declarations (`skipLibCheck: false`) must install Pi’s Google GenAI
18
+ optional declaration dependency themselves:
19
19
 
20
20
  ```sh
21
21
  npm install --save-dev @modelcontextprotocol/sdk
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";
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.
@@ -547,12 +547,16 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
547
547
  try: () => input.retryAfterMs?.(message),
548
548
  catch: (cause) => failure("model", "Model retry delay observation failed", cause),
549
549
  });
550
- const delayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
550
+ const minimumDelayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
551
551
  Number.isSafeInteger(after) &&
552
552
  after >= 0 &&
553
553
  after <= 60_000
554
554
  ? after
555
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);
556
560
  yield* reportRetry({
557
561
  type: "retry_scheduled",
558
562
  run,
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
@@ -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({
@@ -10,15 +11,80 @@ const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
10
11
  response: providerError,
11
12
  }));
12
13
  const decodeErrorReply = Schema.decodeUnknownOption(Schema.fromJsonString(providerError));
14
+ const decodeErrorEvent = Schema.decodeUnknownOption(Schema.Struct({
15
+ type: Schema.Literal("error"),
16
+ status: Schema.optional(Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 100, maximum: 599 }))),
17
+ code: Schema.optional(Schema.String),
18
+ error: Schema.optional(providerError.fields.error),
19
+ }));
13
20
  const decodeBodyChunk = Schema.decodeUnknownSync(Schema.instanceOf(Uint8Array));
14
21
  const errorBodyBytes = 16 * 1024;
22
+ const decodeSeconds = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isBetween({ minimum: 0, maximum: 60 })));
23
+ const decodeReset = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 8_640_000_000_000 })));
24
+ const decodeLimit = Schema.decodeUnknownOption(Schema.Literals([
25
+ "five_hour",
26
+ "seven_day",
27
+ "seven_day_opus",
28
+ "seven_day_sonnet",
29
+ ]));
30
+ const decodeRejected = Schema.decodeUnknownOption(Schema.Literal("rejected"));
31
+ const decodeExhausted = Schema.decodeUnknownOption(Schema.Literal("0"));
32
+ const decodeDate = Schema.decodeUnknownOption(Schema.DateTimeUtcFromString);
33
+ const decodeOverloaded = Schema.decodeUnknownOption(
34
+ // https://docs.anthropic.com/en/api/errors#http-errors
35
+ Schema.Struct({
36
+ type: Schema.Literal("error"),
37
+ error: Schema.Struct({ type: Schema.Literal("overloaded_error") }),
38
+ }));
39
+ function resetDelay(value, now) {
40
+ const date = decodeDate(value);
41
+ if (Option.isNone(date))
42
+ return undefined;
43
+ const delay = DateTime.toEpochMillis(date.value) - now;
44
+ return delay >= 0 && delay <= 60_000 ? Math.ceil(delay) : undefined;
45
+ }
46
+ function rateLimit(headers) {
47
+ const now = Date.now();
48
+ const header = headers.get("retry-after");
49
+ const seconds = decodeSeconds(header);
50
+ let delay = Option.isSome(seconds)
51
+ ? Math.ceil(seconds.value * 1000)
52
+ : resetDelay(header, now);
53
+ // API-key rate windows are not subscription caps. Only an explicitly
54
+ // exhausted dimension supplies a retry floor; missing facts stay unknown.
55
+ for (const dimension of [
56
+ "requests",
57
+ "tokens",
58
+ "input-tokens",
59
+ "output-tokens",
60
+ ]) {
61
+ if (Option.isNone(decodeExhausted(headers.get(`anthropic-ratelimit-${dimension}-remaining`))))
62
+ continue;
63
+ const reset = resetDelay(headers.get(`anthropic-ratelimit-${dimension}-reset`), now);
64
+ if (reset !== undefined)
65
+ delay = Math.max(delay ?? 0, reset);
66
+ }
67
+ return delay !== undefined
68
+ ? { code: "rate-limit", retryAfterMs: delay }
69
+ : { code: "rate-limit" };
70
+ }
71
+ function usageLimit(headers) {
72
+ const reset = decodeReset(headers.get("anthropic-ratelimit-unified-reset"));
73
+ const limit = decodeLimit(headers.get("anthropic-ratelimit-unified-representative-claim"));
74
+ let result = { code: "usage-limit-reached" };
75
+ if (Option.isSome(reset))
76
+ result = { ...result, resetAt: reset.value * 1000 };
77
+ if (Option.isSome(limit))
78
+ result = { ...result, limit: limit.value };
79
+ return result;
80
+ }
15
81
  /** Pi's pinned provider-request transient classes, kept as typed evidence. */
16
82
  const transientStatus = (status) => status === 408 ||
17
83
  status === 409 ||
18
84
  status === 429 ||
19
85
  (status >= 500 && status <= 599);
20
86
  /** Retain Local's bounded error-copy observation; Pi still owns its response. */
21
- async function providerErrorCode(response) {
87
+ async function providerErrorKind(response) {
22
88
  const body = response.clone().body;
23
89
  if (body === null)
24
90
  return undefined;
@@ -39,7 +105,9 @@ async function providerErrorCode(response) {
39
105
  }
40
106
  text += decoder.decode();
41
107
  const decoded = decodeErrorReply(text);
42
- return Option.isSome(decoded) ? decoded.value.error.code : undefined;
108
+ return Option.isSome(decoded)
109
+ ? (decoded.value.error.code ?? decoded.value.error.type)
110
+ : undefined;
43
111
  }
44
112
  finally {
45
113
  // A tee's cancellation may await the original consumer. Do not hold Pi's
@@ -50,6 +118,20 @@ async function providerErrorCode(response) {
50
118
  reader.releaseLock();
51
119
  }
52
120
  }
121
+ /** Retry only typed transient failures, never usage exhaustion or rejection. */
122
+ export function isRetryableProviderRejection(rejection) {
123
+ if (rejection === undefined)
124
+ return false;
125
+ switch (rejection.code) {
126
+ case "provider_unavailable":
127
+ case "rate-limit":
128
+ case "overloaded":
129
+ return true;
130
+ case "usage-limit-reached":
131
+ case "provider_rejected":
132
+ return false;
133
+ }
134
+ }
53
135
  /**
54
136
  * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
55
137
  * decoding; this module retains no provider prose.
@@ -69,15 +151,25 @@ export function observeProviderStatus(options) {
69
151
  throw cause;
70
152
  }
71
153
  const status = response.status;
72
- const code = status === 429 ? await providerErrorCode(response) : undefined;
73
- rejection =
74
- status < 400 || status > 599
75
- ? undefined
76
- : code === usageLimitCode
77
- ? { code: "usage-limit-reached" }
78
- : transientStatus(status) && code !== "insufficient_quota"
79
- ? { code: "provider_unavailable", status }
80
- : { code: "provider_rejected", status };
154
+ const code = status === 429 || status === 529
155
+ ? await providerErrorKind(response)
156
+ : undefined;
157
+ const subscriptionRejected = status === 429 &&
158
+ Option.isSome(decodeRejected(response.headers.get("anthropic-ratelimit-unified-status")));
159
+ if (status < 400 || status > 599)
160
+ rejection = undefined;
161
+ else if (subscriptionRejected)
162
+ rejection = usageLimit(response.headers);
163
+ else if (code === usageLimitCode)
164
+ rejection = { code: "usage-limit-reached" };
165
+ else if (code === "overloaded_error")
166
+ rejection = { code: "overloaded" };
167
+ else if (status === 429 && code !== "insufficient_quota")
168
+ rejection = rateLimit(response.headers);
169
+ else if (transientStatus(status) && code !== "insufficient_quota")
170
+ rejection = { code: "provider_unavailable", status };
171
+ else
172
+ rejection = { code: "provider_rejected", status };
81
173
  return response;
82
174
  };
83
175
  return {
@@ -87,8 +179,43 @@ export function observeProviderStatus(options) {
87
179
  if (Option.isSome(failed) &&
88
180
  failed.value.response.error.code === usageLimitCode)
89
181
  rejection = { code: "usage-limit-reached" };
182
+ // OpenAI's Responses error code, not an inference from overload prose:
183
+ // https://platform.openai.com/docs/api-reference/responses/object#responses/object-error
184
+ if (Option.isSome(failed) &&
185
+ failed.value.response.error.code === "server_error")
186
+ rejection = { code: "provider_unavailable" };
187
+ // WebSocket API errors retain HTTP status in the parsed event:
188
+ // https://developers.openai.com/api/docs/guides/websocket-mode/#handling-errors
189
+ const error = decodeErrorEvent(data);
190
+ if (Option.isSome(error)) {
191
+ const status = error.value.status;
192
+ if (status !== undefined && status >= 500)
193
+ rejection = { code: "provider_unavailable", status };
194
+ else if (status === undefined &&
195
+ (error.value.code ?? error.value.error?.code) === "server_error")
196
+ rejection = { code: "provider_unavailable" };
197
+ }
198
+ if (Option.isSome(decodeOverloaded(data)))
199
+ rejection = { code: "overloaded" };
90
200
  await options?.onProviderStreamEvent?.(data, model);
91
201
  },
92
- rejection: () => rejection,
202
+ rejection: (message) => {
203
+ if (rejection !== undefined)
204
+ return rejection;
205
+ // Pi throws Anthropic event:error before its parsed-event hook and
206
+ // retains that JSON envelope verbatim as the terminal errorMessage.
207
+ // Decode that maintained producer's representation, never match prose.
208
+ const text = message?.errorMessage;
209
+ if (message?.stopReason !== "error" ||
210
+ text === undefined ||
211
+ text.length > errorBodyBytes ||
212
+ new TextEncoder().encode(text).byteLength > errorBodyBytes)
213
+ return undefined;
214
+ const decoded = decodeErrorReply(text);
215
+ return Option.isSome(decoded) &&
216
+ decoded.value.error.type === "overloaded_error"
217
+ ? { code: "overloaded" }
218
+ : undefined;
219
+ },
93
220
  };
94
221
  }
@@ -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
@@ -1912,6 +1920,12 @@ Re-exports [streamModel](index.md#streammodel)
1912
1920
 
1913
1921
  ---
1914
1922
 
1923
+ ### isRetryableProviderRejection
1924
+
1925
+ Re-exports [isRetryableProviderRejection](index.md#isretryableproviderrejection)
1926
+
1927
+ ---
1928
+
1915
1929
  ### observeProviderStatus
1916
1930
 
1917
1931
  Re-exports [observeProviderStatus](index.md#observeproviderstatus)
package/docs/api/index.md CHANGED
@@ -943,6 +943,15 @@ the run, or with the abort reason (an `AbortError` when the reason is not an
943
943
  type ProviderRejection =
944
944
  | Readonly<{
945
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";
946
955
  }>
947
956
  | Readonly<{
948
957
  code: "provider_unavailable";
@@ -966,7 +975,7 @@ type ProviderStatusRecorder = Readonly<{
966
975
  onProviderStreamEvent: NonNullable<
967
976
  SimpleStreamOptions["onProviderStreamEvent"]
968
977
  >;
969
- rejection: () => ProviderRejection | undefined;
978
+ rejection: (message?) => ProviderRejection | undefined;
970
979
  }>;
971
980
  ```
972
981
 
@@ -1040,6 +1049,26 @@ Validate a catalog model's opaque sampling fields before it enters Mule.
1040
1049
 
1041
1050
  ---
1042
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
+
1043
1072
  ### observeProviderStatus()
1044
1073
 
1045
1074
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/mule",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Effect-native Pi model and tool loop",
6
6
  "type": "module",