@fungi.computer/mule 0.0.2 → 0.1.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/README.md CHANGED
@@ -40,6 +40,7 @@ const faux = fauxModel([fauxAssistantMessage("pong")]);
40
40
  const outcome = await mule.run(
41
41
  {
42
42
  model: faux.model,
43
+ retryable: faux.retryable,
43
44
  stream: faux.stream,
44
45
  systemPrompt: "Reply once.",
45
46
  messages: [{ role: "user", content: "ping", timestamp: 1 }],
@@ -1,8 +1,8 @@
1
- Mule's direct runtime dependencies are Effect 3.22.1, Cairn, and
2
- @earendil-works/pi-ai 0.84.4. Consult each dependency's distribution for its
1
+ Mule's direct runtime dependencies are Effect 4.0.2, Cairn, and
2
+ @earendil-works/pi-ai 1.3.0. Consult each dependency's distribution for its
3
3
  license terms.
4
4
 
5
- The Pi dependency is pinned from commit
5
+ The original Pi loop source was pinned from commit
6
6
  853a80d26c90a14c1886f0ebb8ffaae133ca2185 and distributed under the MIT
7
7
  License, Copyright (c) 2025 Mario Zechner. Its independently archived source
8
8
  boundary has SHA-256
@@ -13,3 +13,8 @@ dfb11f4c1c00e47003db12765371198e944c8b640c995a1dc1cf9130bc9e49ee.
13
13
  behavior from Botanical's retained @shiit/agent-core 0.66.2 at commit
14
14
  b6eabe5dfd3cac9514025d26abbe9a92b083f10b, distributed under the MIT License,
15
15
  Copyright (c) 2024 fungi.computer.
16
+
17
+ `src/types.ts` adapts the provider-neutral data declarations from
18
+ @earendil-works/pi-ai 1.3.0, distributed under the MIT License,
19
+ Copyright (c) 2025 Mario Zechner. Mule owns this public structural contract;
20
+ provider implementations remain in Pi.
@@ -1,6 +1,6 @@
1
- import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, TranscriptContext, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, TextContent, ToolCall, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
1
+ import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, TextContent, ToolCall, ToolResultMessage, Usage } from "./types.js";
2
2
  /** Pi's model stream function, supplied explicitly by the caller. */
3
- export type MuleStreamFunction = (model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
3
+ export type MuleStreamFunction = (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
4
4
  /** Causal roles added around Pi's model and tool loop. */
5
5
  export type MuleIdentityKind = "run" | "turn" | "message" | "tool_call" | "tool_result" | "terminal";
6
6
  /** One run-local causal identity. */
@@ -20,7 +20,8 @@ export type MuleError = {
20
20
  /** Result produced by a tool before Mule forms Pi's correlated result message. */
21
21
  export type MuleToolResult = {
22
22
  readonly content: (TextContent | ImageContent)[];
23
- readonly details?: JsonValue | undefined;
23
+ /** Application payload for listeners, never part of a model message. */
24
+ readonly data?: JsonValue | undefined;
24
25
  readonly usage?: Usage | undefined;
25
26
  readonly isError: boolean;
26
27
  readonly terminate?: boolean | undefined;
@@ -79,6 +80,8 @@ export type MuleEvent = {
79
80
  readonly turn: MuleIdentity;
80
81
  readonly message: MuleIdentity;
81
82
  readonly value: Message;
83
+ /** Application payload independent of the completed model message. */
84
+ readonly data?: JsonValue | undefined;
82
85
  } | {
83
86
  readonly type: "tool_execution_start";
84
87
  readonly replay: "safe" | "unsafe";
@@ -147,20 +150,28 @@ export type MuleRunSettings = {
147
150
  /** Continue a durable, completed assistant turn before requesting another response. */
148
151
  readonly resume?: MuleResume | undefined;
149
152
  /** Pi stream options. Cancellation is the run's interruption, never a signal here. */
150
- readonly streamOptions?: Omit<SimpleStreamOptions, "signal"> | undefined;
153
+ /** Provider-native retries are disabled; the typed host policy owns retry. */
154
+ readonly streamOptions?: Omit<SimpleStreamOptions, "signal" | "maxRetries"> | undefined;
151
155
  /** Maximum silence between provider stream events, in milliseconds. */
152
156
  readonly modelStreamIdleTimeoutMs?: number | undefined;
153
157
  readonly toolExecution?: "parallel" | "sequential" | undefined;
154
158
  /**
155
- * Pi's bounded retry policy around each assistant call (`retryAssistantCall`).
159
+ * Bounded retry around each assistant call, using Pi's exponential delay.
156
160
  * Undefined means no retry, matching pi-ai's own default. Hosts that want
157
161
  * Pi coding-agent's numbers pass `piCodingAgentRetryDefaults`.
162
+ * Enabled policy counts and delays must be non-negative safe integers.
163
+ * Each assistant turn captures its policy before requesting the model.
158
164
  */
159
165
  readonly retry?: RetryPolicy | undefined;
166
+ /**
167
+ * Host-owned typed decision for this exact assistant message. Only true
168
+ * permits retry; unknown messages return false, never a decision from prose.
169
+ */
170
+ readonly retryable: (message: AssistantMessage) => boolean;
160
171
  /** Provider's bounded Retry-After evidence for the last assistant error. */
161
172
  readonly retryAfterMs?: ((message: AssistantMessage) => number | undefined) | undefined;
162
173
  };
163
- /** One observed Pi-retry event around a single assistant call. */
174
+ /** One observed typed-retry event around a single assistant call. */
164
175
  export type MuleRetryObservation = {
165
176
  readonly type: "retry_scheduled";
166
177
  readonly run: MuleIdentity;
package/dist/effect.d.ts CHANGED
@@ -1,8 +1,12 @@
1
- import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "@earendil-works/pi-ai";
1
+ export { decodeProviderData } from "./provider-data.js";
2
+ export { streamPi as streamModel } from "./provider.js";
3
+ export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
4
+ export { normalizeModel } from "./provider-model.js";
5
+ import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "./types.js";
2
6
  import * as Effect from "effect/Effect";
3
7
  import * as Stream from "effect/Stream";
4
8
  import type { MuleCompletedTurn, MuleError, MuleEvent, MuleRetryObservation, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
5
- export type { Api, AssistantMessage, AssistantMessageEvent, Context, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, SystemMessage, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "@earendil-works/pi-ai";
9
+ export type * from "./types.js";
6
10
  export { Type } from "typebox";
7
11
  export type { MuleRetryObservation } from "./contract.js";
8
12
  /**
package/dist/effect.js CHANGED
@@ -1,16 +1,19 @@
1
+ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
2
+ export { decodeProviderData } from "./provider-data.js";
3
+ export { streamPi as streamModel } from "./provider.js";
1
4
  import { nanoid } from "@fungi.computer/cairn";
5
+ export { observeProviderStatus, } from "./provider-status.js";
2
6
  // Pi's root entry also evaluates its TypeBox validator compiler and model
3
7
  // stores; Mule needs only these leaf helpers, so it imports them directly and
4
8
  // keeps that work out of every host's startup.
5
9
  import { isContextOverflow as piIsContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
6
- import { retryAssistantCall } from "@earendil-works/pi-ai/utils/retry";
7
- import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
10
+ import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, retryDelayMs, } from "@earendil-works/pi-ai/utils/retry";
11
+ import { streamPi } from "./provider.js";
12
+ export { normalizeModel } from "./provider-model.js";
8
13
  import * as Effect from "effect/Effect";
9
- import * as Schema from "effect/Schema";
10
14
  import * as Cause from "effect/Cause";
11
15
  import * as Result from "effect/Result";
12
16
  import * as Exit from "effect/Exit";
13
- import * as Fiber from "effect/Fiber";
14
17
  import * as Option from "effect/Option";
15
18
  import * as Semaphore from "effect/Semaphore";
16
19
  import * as Stream from "effect/Stream";
@@ -44,13 +47,6 @@ export function isContextOverflow(message, contextWindow) {
44
47
  return piIsContextOverflow(message, contextWindow);
45
48
  }
46
49
  const failure = (phase, message, cause) => ({ _tag: "MuleError", phase, message, cause });
47
- const muleErrorSchema = Schema.Struct({
48
- _tag: Schema.Literal("MuleError"),
49
- phase: Schema.Literals(["model", "tool", "control", "listener"]),
50
- message: Schema.String,
51
- cause: Schema.optionalKey(Schema.Unknown),
52
- });
53
- const isMuleError = (value) => Schema.is(muleErrorSchema)(value);
54
50
  const identity = (kind, parent) => ({ kind, value: `mule_${kind}_${nanoid()}`, parent });
55
51
  const noListener = () => Effect.void;
56
52
  function resultMessage(call, result) {
@@ -62,8 +58,6 @@ function resultMessage(call, result) {
62
58
  isError: result.isError,
63
59
  timestamp: Date.now(),
64
60
  };
65
- if (result.details !== undefined)
66
- message.details = result.details;
67
61
  if (result.usage !== undefined)
68
62
  message.usage = result.usage;
69
63
  return message;
@@ -174,6 +168,7 @@ function emitToolResult(executed, run, turn, emit) {
174
168
  turn,
175
169
  message: messageIdentity,
176
170
  value: message,
171
+ data: executed.result.data,
177
172
  });
178
173
  return message;
179
174
  });
@@ -261,6 +256,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
261
256
  input.streamOptions?.apiKey;
262
257
  const options = {
263
258
  ...input.streamOptions,
259
+ maxRetries: 0,
264
260
  signal: controller.signal,
265
261
  };
266
262
  if (resolvedApiKey !== undefined)
@@ -279,8 +275,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
279
275
  piContext.tools = descriptors;
280
276
  const response = yield* withModelStreamDeadline(Effect.tryPromise({
281
277
  try: async () => {
282
- const stream = input.stream ??
283
- (await import("@earendil-works/pi-ai/compat")).streamSimple;
278
+ const stream = input.stream ?? streamPi;
284
279
  return stream(input.model, normalizeContext(piContext), options);
285
280
  },
286
281
  catch: (cause) => failure("model", "Pi model stream could not start", cause),
@@ -384,9 +379,8 @@ function streamTurn(input, context, run, turn, emit, attempt) {
384
379
  }));
385
380
  }
386
381
  /**
387
- * Run one assistant call with Pi's bounded retry (`retryAssistantCall`)
388
- * around it. `input.retry` undefined or disabled means `retryAssistantCall`
389
- * calls `streamTurn` exactly once and returns its result unchanged.
382
+ * Retry only the host's typed transient decision, using Pi's maintained delay.
383
+ * Effect owns interruption of both the active stream and the backoff sleep.
390
384
  *
391
385
  * Each attempt streams and is recorded exactly like any other turn (so a
392
386
  * failed attempt stays in the retained event log for inspection), but a
@@ -395,123 +389,91 @@ function streamTurn(input, context, run, turn, emit, attempt) {
395
389
  */
396
390
  function streamTurnWithRetry(input, context, run, turn, emit) {
397
391
  return Effect.gen(function* () {
398
- const services = yield* Effect.context();
399
- const runFork = Effect.runForkWith(services);
400
- const runPromise = Effect.runPromiseWith(services);
392
+ // One turn owns one finite budget, even if its caller updates future policy.
393
+ const policy = input.retry === undefined ? undefined : { ...input.retry };
394
+ if (policy?.enabled &&
395
+ [
396
+ policy.maxRetries,
397
+ policy.baseDelayMs,
398
+ policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS,
399
+ ].some((value) => !Number.isSafeInteger(value) || value < 0))
400
+ return yield* Effect.fail(failure("model", "Retry policy requires non-negative safe-integer counts and delays"));
401
401
  let latest;
402
- let attempt = 0;
403
- let retrySignal;
404
- const reportRetry = (event) => input.onRetry === undefined
405
- ? undefined
406
- : runPromise(input.onRetry(event));
407
- const callbacks = {
408
- onRetryScheduled: async (attempt, maxAttempts, delayMs, errorMessage) => {
409
- // Pi owns the only retry loop and its exponential schedule. A clean
410
- // gateway 429 can request a longer delay; extend that same scheduled
411
- // sleep (capped), never dispatch a second transport-level retry.
412
- const after = latest === undefined
413
- ? undefined
414
- : input.retryAfterMs?.(latest.message);
415
- const extra = after !== undefined &&
402
+ let calls = 0;
403
+ let retries = 0;
404
+ let finished = false;
405
+ let lastError;
406
+ const reportRetry = (event) => input.onRetry === undefined ? Effect.void : input.onRetry(event);
407
+ const finish = (success, finalError) => Effect.gen(function* () {
408
+ finished = true;
409
+ yield* reportRetry(finalError === undefined
410
+ ? { type: "retry_finished", run, turn, attempt: retries, success }
411
+ : {
412
+ type: "retry_finished",
413
+ run,
414
+ turn,
415
+ attempt: retries,
416
+ success,
417
+ finalError,
418
+ });
419
+ });
420
+ return yield* Effect.gen(function* () {
421
+ for (;;) {
422
+ if (latest !== undefined) {
423
+ const index = context.indexOf(latest.message);
424
+ if (index !== -1)
425
+ context.splice(index, 1);
426
+ }
427
+ latest = yield* streamTurn(input, context, run, turn, emit, ++calls);
428
+ const message = latest.message;
429
+ if (message.stopReason !== "error" ||
430
+ policy === undefined ||
431
+ !policy.enabled ||
432
+ retries >= policy.maxRetries) {
433
+ if (retries > 0)
434
+ yield* finish(message.stopReason !== "error" &&
435
+ message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
436
+ return latest;
437
+ }
438
+ const retryable = yield* Effect.try({
439
+ try: () => input.retryable(message),
440
+ catch: (cause) => failure("model", "Typed model retry decision failed", cause),
441
+ });
442
+ if (!retryable) {
443
+ if (retries > 0)
444
+ yield* finish(false, message.errorMessage);
445
+ return latest;
446
+ }
447
+ retries++;
448
+ lastError = message.errorMessage ?? "Unknown error";
449
+ const after = yield* Effect.try({
450
+ try: () => input.retryAfterMs?.(message),
451
+ catch: (cause) => failure("model", "Model retry delay observation failed", cause),
452
+ });
453
+ const delayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
416
454
  Number.isSafeInteger(after) &&
417
455
  after >= 0 &&
418
456
  after <= 60_000
419
- ? Math.max(0, after - delayMs)
420
- : 0;
421
- await reportRetry({
457
+ ? after
458
+ : 0);
459
+ yield* reportRetry({
422
460
  type: "retry_scheduled",
423
461
  run,
424
462
  turn,
425
- attempt,
426
- maxAttempts,
427
- delayMs: delayMs + extra,
428
- errorMessage,
463
+ attempt: retries,
464
+ maxAttempts: policy.maxRetries,
465
+ delayMs,
466
+ errorMessage: lastError,
429
467
  });
430
- if (extra > 0)
431
- await new Promise((resolve) => {
432
- const timer = setTimeout(() => {
433
- retrySignal?.removeEventListener("abort", abort);
434
- resolve();
435
- }, extra);
436
- const abort = () => {
437
- clearTimeout(timer);
438
- resolve();
439
- };
440
- retrySignal?.addEventListener("abort", abort, { once: true });
441
- if (retrySignal?.aborted)
442
- abort();
443
- });
444
- },
445
- onRetryAttemptStart: () => reportRetry({ type: "retry_attempt_started", run, turn, attempt }),
446
- onRetryFinished: (success, attempt, finalError) => reportRetry(finalError === undefined
447
- ? { type: "retry_finished", run, turn, attempt, success }
448
- : {
449
- type: "retry_finished",
468
+ yield* Effect.sleep(delayMs);
469
+ yield* reportRetry({
470
+ type: "retry_attempt_started",
450
471
  run,
451
472
  turn,
452
- attempt,
453
- success,
454
- finalError,
455
- }),
456
- };
457
- const result = yield* Effect.tryPromise({
458
- try: (signal) => {
459
- retrySignal = signal;
460
- // `retryAssistantCall` only threads `signal` through its own
461
- // backoff sleep, never into an in-flight `produce`. Fork `streamTurn`
462
- // as its own fiber and interrupt it on `signal` so a cancellation
463
- // reaches an active call too, exactly like interrupting this fiber
464
- // reached `streamTurn` before retry wrapped it.
465
- const produce = async () => {
466
- if (latest !== undefined) {
467
- const index = context.indexOf(latest.message);
468
- if (index !== -1)
469
- context.splice(index, 1);
470
- }
471
- const fiber = runFork(streamTurn(input, context, run, turn, emit, ++attempt));
472
- const interrupt = () => {
473
- runFork(Fiber.interrupt(fiber));
474
- };
475
- if (signal.aborted)
476
- interrupt();
477
- else
478
- signal.addEventListener("abort", interrupt, { once: true });
479
- const exit = await runPromise(Fiber.await(fiber));
480
- signal.removeEventListener("abort", interrupt);
481
- // `streamTurn`'s own Effect failures (a transport exception, a
482
- // listener failure, or this fiber's own interruption) are not
483
- // Pi-protocol error messages for `retryAssistantCall` to classify:
484
- // unwrap and rethrow the original so the surrounding
485
- // `Effect.tryPromise` reports it unchanged, not behind a new
486
- // "Pi retry loop failed" layer.
487
- if (Exit.isFailure(exit)) {
488
- const error = Cause.findErrorOption(exit.cause);
489
- throw Option.isSome(error) ? error.value : Cause.squash(exit.cause);
490
- }
491
- latest = exit.value;
492
- return latest.message;
493
- };
494
- return retryAssistantCall(produce, input.retry, signal, callbacks);
495
- },
496
- catch: (cause) => isMuleError(cause)
497
- ? cause
498
- : failure("model", "Pi retry loop failed", cause),
499
- });
500
- // `produce` always runs at least once before `retryAssistantCall`
501
- // settles, so `latest` is always set here.
502
- if (latest === undefined)
503
- return yield* Effect.die("streamTurnWithRetry: retryAssistantCall settled without calling produce");
504
- const settled = latest;
505
- if (result !== settled.message) {
506
- // Pi normalizes an abort during backoff sleep into a fresh
507
- // AssistantMessage (the failed attempt's content, `stopReason`
508
- // forced to "aborted") instead of replaying `produce`. Reconcile the
509
- // dangling failed entry `context` is still holding with that value.
510
- const index = context.indexOf(settled.message);
511
- if (index !== -1)
512
- context[index] = result;
513
- }
514
- return { identity: settled.identity, message: result };
473
+ attempt: retries,
474
+ });
475
+ }
476
+ }).pipe(Effect.onInterrupt(() => retries === 0 || finished ? Effect.void : finish(false, lastError)));
515
477
  });
516
478
  }
517
479
  const statusFor = (message) => message.stopReason === "aborted"
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { Message, Tool, ToolCall } from "@earendil-works/pi-ai";
1
+ export { decodeProviderData } from "./provider-data.js";
2
+ export { streamPi as streamModel } from "./provider.js";
3
+ export { normalizeModel } from "./provider-model.js";
4
+ import type { Message, Tool, ToolCall } from "./types.js";
5
+ export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
2
6
  import type { MuleCompletedTurn, MuleEvent, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
3
7
  export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
4
8
  /** Promise listener adapted once into Mule's canonical Effect program. */
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ export { decodeProviderData } from "./provider-data.js";
2
+ export { streamPi as streamModel } from "./provider.js";
3
+ export { normalizeModel } from "./provider-model.js";
4
+ export { observeProviderStatus, } from "./provider-status.js";
1
5
  import * as Cause from "effect/Cause";
2
6
  import * as Effect from "effect/Effect";
3
7
  import * as Exit from "effect/Exit";
@@ -0,0 +1,6 @@
1
+ import type { SimpleStreamOptions as PiOptions } from "@earendil-works/pi-ai";
2
+ import type { SimpleStreamOptions } from "./types.js";
3
+ /** Adapt Pi's parsed payload and event boundary before invoking owned hooks. */
4
+ export declare function piOptions(options: SimpleStreamOptions | undefined): PiOptions | undefined;
5
+ /** Restore the owned options shape when Pi calls a scripted provider factory. */
6
+ export declare function muleOptions(options: PiOptions | undefined): SimpleStreamOptions | undefined;
@@ -0,0 +1,35 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { decodeProviderData } from "./provider-data.js";
3
+ import { normalizeModel } from "./provider-model.js";
4
+ /** Adapt Pi's parsed payload and event boundary before invoking owned hooks. */
5
+ export function piOptions(options) {
6
+ if (options === undefined)
7
+ return undefined;
8
+ const { onPayload, onProviderStreamEvent, onResponse, ...rest } = options;
9
+ const adapted = { ...rest };
10
+ if (onPayload !== undefined)
11
+ adapted.onPayload = (payload, model) => onPayload(decodeProviderData(payload), normalizeModel(model));
12
+ if (onProviderStreamEvent !== undefined)
13
+ adapted.onProviderStreamEvent = (event, model) => onProviderStreamEvent(decodeProviderData(event), normalizeModel(model));
14
+ if (onResponse !== undefined)
15
+ adapted.onResponse = (response, model) => onResponse(response, normalizeModel(model));
16
+ return adapted;
17
+ }
18
+ /** Restore the owned options shape when Pi calls a scripted provider factory. */
19
+ export function muleOptions(options) {
20
+ if (options === undefined)
21
+ return undefined;
22
+ const { samplingParams, metadata, onPayload, ...rest } = options;
23
+ const adapted = { ...rest };
24
+ const decodeObject = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json));
25
+ if (samplingParams !== undefined)
26
+ adapted.samplingParams = decodeObject(samplingParams);
27
+ if (metadata !== undefined)
28
+ adapted.metadata = decodeObject(metadata);
29
+ if (onPayload !== undefined)
30
+ adapted.onPayload = async (payload, model) => {
31
+ const result = await onPayload(payload, model);
32
+ return result === undefined ? undefined : decodeProviderData(result);
33
+ };
34
+ return adapted;
35
+ }
@@ -0,0 +1,3 @@
1
+ import type { ProviderData } from "./types.js";
2
+ /** Decode Pi's parsed request/event data before it enters an owned hook. */
3
+ export declare function decodeProviderData(data: unknown): ProviderData;
@@ -0,0 +1,17 @@
1
+ import * as Schema from "effect/Schema";
2
+ const providerValue = Schema.suspend(() => Schema.Union([
3
+ Schema.Undefined,
4
+ Schema.Null,
5
+ Schema.Boolean,
6
+ Schema.Number,
7
+ Schema.String,
8
+ Schema.Uint8Array,
9
+ Schema.instanceOf(AbortSignal),
10
+ Schema.Array(providerValue),
11
+ providerData,
12
+ ]));
13
+ const providerData = Schema.Record(Schema.String, providerValue);
14
+ /** Decode Pi's parsed request/event data before it enters an owned hook. */
15
+ export function decodeProviderData(data) {
16
+ return Schema.decodeUnknownSync(providerData)(data);
17
+ }
@@ -0,0 +1,3 @@
1
+ import type { Api, Model } from "./types.js";
2
+ /** Validate a catalog model's opaque sampling fields before it enters Mule. */
3
+ export declare function normalizeModel<TApi extends Api>(model: Omit<Model<TApi>, "samplingParams" | "samplingParamsByThinkingLevel">): Model<TApi>;
@@ -0,0 +1,18 @@
1
+ import * as Schema from "effect/Schema";
2
+ const sampling = Schema.Record(Schema.String, Schema.Json);
3
+ const decodeSampling = Schema.decodeUnknownSync(Schema.Struct({
4
+ samplingParams: Schema.optionalKey(sampling),
5
+ samplingParamsByThinkingLevel: Schema.optionalKey(Schema.Struct({
6
+ off: Schema.optionalKey(sampling),
7
+ minimal: Schema.optionalKey(sampling),
8
+ low: Schema.optionalKey(sampling),
9
+ medium: Schema.optionalKey(sampling),
10
+ high: Schema.optionalKey(sampling),
11
+ xhigh: Schema.optionalKey(sampling),
12
+ max: Schema.optionalKey(sampling),
13
+ })),
14
+ }));
15
+ /** Validate a catalog model's opaque sampling fields before it enters Mule. */
16
+ export function normalizeModel(model) {
17
+ return { ...model, ...decodeSampling(model) };
18
+ }
@@ -0,0 +1,25 @@
1
+ import type { SimpleStreamOptions } from "./types.js";
2
+ /** Bounded provider evidence, never response bodies, headers or error prose. */
3
+ export type ProviderRejection = Readonly<{
4
+ code: "usage-limit-reached";
5
+ }> | Readonly<{
6
+ code: "provider_unavailable";
7
+ status?: number;
8
+ }> | Readonly<{
9
+ code: "provider_rejected";
10
+ status: number;
11
+ }>;
12
+ /** One provider call's transport and parsed-event observations. */
13
+ export type ProviderStatusRecorder = Readonly<{
14
+ /** Request-local fetch observer, retaining the supplied transport. */
15
+ fetch: typeof globalThis.fetch;
16
+ /** Pi's maintained parsed-event hook, composed with the supplied observer. */
17
+ onProviderStreamEvent: NonNullable<SimpleStreamOptions["onProviderStreamEvent"]>;
18
+ /** Latest typed failure evidence for this call, or no observed failure. */
19
+ rejection: () => ProviderRejection | undefined;
20
+ }>;
21
+ /**
22
+ * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
23
+ * decoding; this module retains no provider prose.
24
+ */
25
+ export declare function observeProviderStatus(options?: Pick<SimpleStreamOptions, "fetch" | "onProviderStreamEvent">): ProviderStatusRecorder;
@@ -0,0 +1,94 @@
1
+ import { Option, Schema } from "effect";
2
+ const usageLimitCode = "subscription_sharing_usage_limit_exceeded";
3
+ const providerError = Schema.Struct({
4
+ error: Schema.Struct({
5
+ code: Schema.String,
6
+ }),
7
+ });
8
+ const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
9
+ type: Schema.Literal("response.failed"),
10
+ response: providerError,
11
+ }));
12
+ const decodeErrorReply = Schema.decodeUnknownOption(Schema.fromJsonString(providerError));
13
+ const decodeBodyChunk = Schema.decodeUnknownSync(Schema.instanceOf(Uint8Array));
14
+ const errorBodyBytes = 16 * 1024;
15
+ /** Pi's pinned provider-request transient classes, kept as typed evidence. */
16
+ const transientStatus = (status) => status === 408 ||
17
+ status === 409 ||
18
+ status === 429 ||
19
+ (status >= 500 && status <= 599);
20
+ /** Retain Local's bounded error-copy observation; Pi still owns its response. */
21
+ async function providerErrorCode(response) {
22
+ const body = response.clone().body;
23
+ if (body === null)
24
+ return undefined;
25
+ const reader = body.getReader();
26
+ const decoder = new TextDecoder();
27
+ let bytes = 0;
28
+ let text = "";
29
+ try {
30
+ for (;;) {
31
+ const chunk = await reader.read();
32
+ if (chunk.done)
33
+ break;
34
+ const value = decodeBodyChunk(chunk.value);
35
+ bytes += value.byteLength;
36
+ if (bytes > errorBodyBytes)
37
+ return undefined;
38
+ text += decoder.decode(value, { stream: true });
39
+ }
40
+ text += decoder.decode();
41
+ const decoded = decodeErrorReply(text);
42
+ return Option.isSome(decoded) ? decoded.value.error.code : undefined;
43
+ }
44
+ finally {
45
+ // A tee's cancellation may await the original consumer. Do not hold Pi's
46
+ // response hostage to that consumer; dispose only our observation branch.
47
+ void reader.cancel().catch((cause) => {
48
+ console.error("Provider error-copy cancellation failed", cause);
49
+ });
50
+ reader.releaseLock();
51
+ }
52
+ }
53
+ /**
54
+ * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
55
+ * decoding; this module retains no provider prose.
56
+ */
57
+ export function observeProviderStatus(options) {
58
+ let rejection;
59
+ const upstream = options?.fetch ?? globalThis.fetch;
60
+ const observed = async (input, init) => {
61
+ rejection = undefined;
62
+ let response;
63
+ try {
64
+ response = await upstream(input, init);
65
+ }
66
+ catch (cause) {
67
+ if (init?.signal?.aborted !== true)
68
+ rejection = { code: "provider_unavailable" };
69
+ throw cause;
70
+ }
71
+ 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 };
81
+ return response;
82
+ };
83
+ return {
84
+ fetch: Object.assign(observed, upstream),
85
+ onProviderStreamEvent: async (data, model) => {
86
+ const failed = decodeFailedEvent(data);
87
+ if (Option.isSome(failed) &&
88
+ failed.value.response.error.code === usageLimitCode)
89
+ rejection = { code: "usage-limit-reached" };
90
+ await options?.onProviderStreamEvent?.(data, model);
91
+ },
92
+ rejection: () => rejection,
93
+ };
94
+ }
@@ -0,0 +1,3 @@
1
+ import type { MuleStreamFunction } from "./contract.js";
2
+ /** Pi's maintained stream behind Mule's structural provider port. */
3
+ export declare const streamPi: MuleStreamFunction;
@@ -0,0 +1,7 @@
1
+ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
2
+ import { piOptions } from "./pi-options.js";
3
+ /** Pi's maintained stream behind Mule's structural provider port. */
4
+ export const streamPi = async (model, context, options) => {
5
+ const { streamSimple } = await import("@earendil-works/pi-ai/compat");
6
+ return streamSimple(model, normalizeContext(context), piOptions(options));
7
+ };