@arnilo/prism 0.0.16 → 0.0.17

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/agent-run-state.js +13 -1
  3. package/dist/agents.js +42 -10
  4. package/dist/checkpoints.d.ts +4 -0
  5. package/dist/checkpoints.js +12 -0
  6. package/dist/cli-runner.d.ts +1 -5
  7. package/dist/cli-runner.js +5 -28
  8. package/dist/context-budget.js +6 -3
  9. package/dist/contracts.d.ts +13 -0
  10. package/dist/contributions.d.ts +2 -0
  11. package/dist/contributions.js +3 -0
  12. package/dist/credentials.d.ts +7 -1
  13. package/dist/credentials.js +6 -2
  14. package/dist/event-multiplexer.js +17 -1
  15. package/dist/extensions.d.ts +7 -1
  16. package/dist/extensions.js +64 -6
  17. package/dist/feedback.js +1 -1
  18. package/dist/guardrails.js +9 -3
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js +1 -1
  21. package/dist/input.js +11 -4
  22. package/dist/middleware.js +9 -1
  23. package/dist/models.d.ts +2 -0
  24. package/dist/models.js +3 -0
  25. package/dist/providers/openai-compatible.d.ts +42 -1
  26. package/dist/providers/openai-compatible.js +109 -47
  27. package/dist/providers/transport.d.ts +6 -0
  28. package/dist/providers/transport.js +21 -0
  29. package/dist/providers.d.ts +2 -0
  30. package/dist/providers.js +3 -0
  31. package/dist/redaction.js +21 -7
  32. package/dist/retry.d.ts +5 -0
  33. package/dist/retry.js +8 -1
  34. package/dist/run-ledger.d.ts +6 -0
  35. package/dist/run-ledger.js +3 -9
  36. package/dist/session-stores.js +15 -11
  37. package/docs/agent-events.md +2 -1
  38. package/docs/agent-session-runtime.md +2 -2
  39. package/docs/cli-rpc.md +1 -5
  40. package/docs/coding-agent-tools.md +2 -0
  41. package/docs/compaction-and-retry.md +3 -1
  42. package/docs/contribution-registries.md +1 -0
  43. package/docs/credentials-and-redaction.md +1 -1
  44. package/docs/extensions.md +1 -1
  45. package/docs/guardrails.md +13 -2
  46. package/docs/index.md +2 -2
  47. package/docs/input-and-prompt-assembly.md +3 -3
  48. package/docs/middleware-hooks.md +2 -2
  49. package/docs/migration.md +11 -0
  50. package/docs/providers/openai-compatible.md +28 -1
  51. package/docs/public-contracts.md +1 -1
  52. package/docs/release-and-install.md +36 -15
  53. package/docs/session-stores.md +1 -1
  54. package/package.json +1 -1
package/dist/feedback.js CHANGED
@@ -216,7 +216,7 @@ function freezeRecord(record) {
216
216
  });
217
217
  }
218
218
  function cloneFrozenJsonObject(value) {
219
- const cloned = JSON.parse(JSON.stringify(value));
219
+ const cloned = structuredClone(value);
220
220
  if (!cloned || typeof cloned !== "object" || Array.isArray(cloned))
221
221
  throw new RunFeedbackError("metadata must be a JSON object");
222
222
  return deepFreeze(cloned);
@@ -5,7 +5,9 @@ export class GuardrailError extends Error {
5
5
  code;
6
6
  record;
7
7
  constructor(record) {
8
- super(record.action === "interrupt" ? "Guardrail interruption is unavailable" : "Guardrail blocked run");
8
+ super(record.action === "interrupt"
9
+ ? `Guardrail interruption is unavailable at stage "${record.stage}"; interrupt suspends only at the input stage of durable runs`
10
+ : "Guardrail blocked run");
9
11
  this.name = "GuardrailError";
10
12
  this.code = record.action === "interrupt" ? "ERR_PRISM_GUARDRAIL_INTERRUPT_UNAVAILABLE" : "ERR_PRISM_GUARDRAIL_BLOCKED";
11
13
  this.record = record;
@@ -88,8 +90,12 @@ async function evaluate(guardrail, options, signal) {
88
90
  }
89
91
  return record(guardrail.name, options.stage, decision.action, decision.reason, decision.metadata, options.redactor);
90
92
  }
91
- catch {
92
- return record(guardrail.name, options.stage, "tripwire", "guardrail_failed", undefined, options.redactor);
93
+ catch (error) {
94
+ // Keep the cause diagnosable without leaking internals: message only, bounded and
95
+ // passed through the same redact+bound metadata path as guardrail-provided metadata.
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ const redacted = options.redactor?.redact(message) ?? message;
98
+ return record(guardrail.name, options.stage, "tripwire", "guardrail_failed", { error: boundText(redacted, MAX_REASON_BYTES) }, options.redactor);
93
99
  }
94
100
  }
95
101
  function resolveConcurrency(value) {
package/dist/index.d.ts CHANGED
@@ -35,7 +35,7 @@ export type { EventMultiplexer, EventMultiplexerOptions, EventOverflowInfo, Even
35
35
  export { createEventMultiplexer } from "./event-multiplexer.js";
36
36
  export type { ExecutionAction, ExecutionDecision, ExecutionPolicy, ExecutionRisk } from "./execution-policy.js";
37
37
  export { applyExecutionDecision, assertExecutionAllowed, checkExecution, ExecutionDeniedError } from "./execution-policy.js";
38
- export type { ExtensionErrorPolicy, ExtensionEventBus, ExtensionEventHandler, ExtensionKernel, ExtensionKernelOptions, ExtensionLoadPolicy, } from "./extensions.js";
38
+ export type { ExtensionErrorPolicy, ExtensionEventBus, ExtensionEventHandler, ExtensionKernel, ExtensionKernelOptions, ExtensionLoadPolicy, LoadedExtension, } from "./extensions.js";
39
39
  export { createExtensionEventBus, createExtensionKernel } from "./extensions.js";
40
40
  export type { MemoryRunFeedbackStoreOptions, PrepareRunFeedbackOptions, RunFeedbackLimits, RunFeedbackRun, RunFeedbackRunResolver, } from "./feedback.js";
41
41
  export { createMemoryRunFeedbackStore, prepareRunFeedback, RunFeedbackError, requireRunFeedbackOwnership, runFeedbackPageLimit, } from "./feedback.js";
@@ -94,5 +94,5 @@ export { createToolParameterValidator, createToolRegistry, dispatchToolCall, fil
94
94
  export type { ResolvedUseCaseModel, ResolveUseCaseModelInput, UseCaseModelBinding, } from "./use-case-model.js";
95
95
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
96
96
  export declare const name = "prism";
97
- export declare const version = "0.0.16";
97
+ export declare const version = "0.0.17";
98
98
  export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
package/dist/index.js CHANGED
@@ -51,6 +51,6 @@ export { applyThinkingLevel, isThinkingLevel, normalizeThinkingLevel, THINKING_L
51
51
  export { createToolParameterValidator, createToolRegistry, dispatchToolCall, filterTools } from "./tools.js";
52
52
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
53
53
  export const name = "prism";
54
- export const version = "0.0.16";
54
+ export const version = "0.0.17";
55
55
  export const description = "Agent harness for AI providers, agents, sessions, and tools.";
56
56
  //# sourceMappingURL=index.js.map
package/dist/input.js CHANGED
@@ -9,8 +9,9 @@ export function createDefaultInputBuilder() {
9
9
  name: "default-input",
10
10
  async build(input, context = {}) {
11
11
  const groups = await buildDefaultInputMessageGroups(input, context);
12
- const messages = flattenInputGroups(groups, context.inputLayout ?? "legacy");
13
- return context.middleware ? context.middleware.run("input_assembly", messages) : messages;
12
+ // `input_assembly` middleware is applied by assembleProviderInput after build(),
13
+ // never here: builders must not be a security seam.
14
+ return flattenInputGroups(groups, context.inputLayout ?? "legacy");
14
15
  },
15
16
  };
16
17
  }
@@ -35,7 +36,10 @@ export function createDefaultPromptBuilder() {
35
36
  return {
36
37
  name: "default-prompt",
37
38
  async build(request) {
38
- return [...contextMessages(request.context), ...skillMessages(request.skills), ...toolMessages(request.tools), ...request.messages];
39
+ // Tool-capable models receive schemas via request.tools; the text list only serves
40
+ // text-only (or unknown-capability) models — duplicating it doubles tool tokens per turn.
41
+ const tools = request.model?.capabilities?.tools === true ? undefined : request.tools;
42
+ return [...contextMessages(request.context), ...skillMessages(request.skills), ...toolMessages(tools), ...request.messages];
39
43
  },
40
44
  };
41
45
  }
@@ -113,6 +117,9 @@ export async function assembleProviderInput(options) {
113
117
  else {
114
118
  const inputBuilder = options.inputBuilder ?? createDefaultInputBuilder();
115
119
  messages = await inputBuilder.build(options.input, buildContext);
120
+ // Runtime-owned: input_assembly middleware always runs, regardless of which builder is installed.
121
+ if (buildContext.middleware)
122
+ messages = await buildContext.middleware.run("input_assembly", messages);
116
123
  context = await resolveContextProviders({
117
124
  providers: options.contextProviders,
118
125
  messages,
@@ -139,7 +146,7 @@ export async function assembleProviderInput(options) {
139
146
  metadata: options.metadata,
140
147
  signal: options.signal,
141
148
  };
142
- const providerMessages = await promptBuilder.build({ ...promptRequest, tools });
149
+ const providerMessages = await promptBuilder.build({ ...promptRequest, tools, model: options.model });
143
150
  assertMessagesSupportModelCapabilities(options.model, providerMessages);
144
151
  const metadata = budgetReport ? { ...options.metadata, [CONTEXT_BUDGET_REPORT_METADATA_KEY]: budgetReport } : options.metadata;
145
152
  return {
@@ -21,10 +21,13 @@ export function createMiddlewareRegistry(options = {}) {
21
21
  },
22
22
  async run(hook, value) {
23
23
  let current = value;
24
- for (const middleware of byHook.get(hook) ?? []) {
24
+ for (const [index, middleware] of (byHook.get(hook) ?? []).entries()) {
25
25
  try {
26
26
  let calledNext = false;
27
27
  const next = async (nextValue) => {
28
+ // Double next() forks the chain value nondeterministically — always a bug.
29
+ if (calledNext)
30
+ throw new Error(`Middleware hook "${hook}" #${index}: next() called more than once`);
28
31
  calledNext = true;
29
32
  current = nextValue;
30
33
  return current;
@@ -32,6 +35,11 @@ export function createMiddlewareRegistry(options = {}) {
32
35
  const result = await middleware(current, next);
33
36
  if (!calledNext)
34
37
  current = result;
38
+ else if (result !== undefined && result !== current) {
39
+ // next(v) already committed the chain value; a conflicting return is silently
40
+ // discarded. Ambiguous rather than certainly-wrong, so diagnose, don't throw.
41
+ await options.onError?.(middlewareError(new Error(`Middleware hook "${hook}" #${index}: called next(value) and returned a different value; the next() value wins, the return is discarded`), hook, secrets));
42
+ }
35
43
  }
36
44
  catch (error) {
37
45
  if (errorPolicy === "throw")
package/dist/models.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { ModelConfig } from "./contracts.js";
2
2
  import { type DuplicateRegistrationOptions } from "./registry-options.js";
3
3
  export interface ModelRegistry {
4
4
  register(model: ModelConfig): void;
5
+ /** Remove a model; returns false when it was not registered. */
6
+ unregister(provider: string, model: string): boolean;
5
7
  get(provider: string, model: string): ModelConfig | undefined;
6
8
  resolve(provider: string, model: string): ModelConfig;
7
9
  list(): readonly ModelConfig[];
package/dist/models.js CHANGED
@@ -8,6 +8,9 @@ export function createModelRegistry(models = [], options = {}) {
8
8
  assertCanRegister(byId, id, "model", `${model.provider}/${model.model}`, options.duplicate);
9
9
  byId.set(id, model);
10
10
  },
11
+ unregister(provider, model) {
12
+ return byId.delete(key(provider, model));
13
+ },
11
14
  get(provider, model) {
12
15
  return byId.get(key(provider, model));
13
16
  },
@@ -1,4 +1,4 @@
1
- import type { AIProvider, ProviderRequest } from "../contracts.js";
1
+ import type { AIProvider, JsonObject, Message, ProviderEvent, ProviderRequest, Usage } from "../contracts.js";
2
2
  import { type CredentialValueSource } from "../credentials.js";
3
3
  export interface OpenAICompatibleProviderOptions {
4
4
  readonly id?: string;
@@ -9,5 +9,46 @@ export interface OpenAICompatibleProviderOptions {
9
9
  readonly chatCompletionsUrl?: string | ((request: ProviderRequest) => string);
10
10
  /** Default `bearer`. Azure resource keys use `api-key`; host-signed fetches may use `none`. */
11
11
  readonly authStyle?: "bearer" | "api-key" | "none";
12
+ /** Extra provider-specific body fields (thinking/reasoning/cache); merged over the base body. */
13
+ readonly buildBodyExtra?: (request: ProviderRequest) => JsonObject | undefined;
14
+ /** Transform messages before serialization (e.g. cache-control markers). Defaults to `request.messages`. */
15
+ readonly mapMessages?: (request: ProviderRequest) => readonly Message[];
16
+ /** Custom message serializer (e.g. Z.AI `reasoning_content` replay). Defaults to assert + `serializeOpenAIChatMessage`. */
17
+ readonly serializeMessage?: (message: Message, request: ProviderRequest) => JsonObject;
18
+ /** Custom usage mapping (e.g. OpenRouter cost fields). Defaults to `mapOpenAIChatUsage`. */
19
+ readonly mapUsage?: (usage: unknown) => Usage | undefined;
20
+ /** Extra request headers (merged over caller headers; provider auth/content-type still win). */
21
+ readonly extraHeaders?: (request: ProviderRequest) => Record<string, string>;
22
+ /** Final body transform applied last (token limits, compat stripping). Wins over everything. */
23
+ readonly transformBody?: (body: JsonObject, request: ProviderRequest) => JsonObject;
24
+ /** Require `[DONE]` and a `finish_reason` before emitting `done`; truncated streams yield an error. `done` then carries the final usage. */
25
+ readonly strictCompletion?: boolean;
26
+ /** Emit the final stream usage on the `done` event (without strict completion checks). */
27
+ readonly doneUsage?: boolean;
28
+ /** Prefix for HTTP error messages (default `OpenAI-compatible request failed`). */
29
+ readonly requestFailedPrefix?: string;
30
+ /** Custom HTTP error mapping (e.g. NeuralWatt retry classification). Receives the response and redacted body text. */
31
+ readonly mapHttpError?: (response: Response, bodyText: string, secrets: readonly (string | undefined)[]) => Error;
32
+ /** Handle SSE comment lines in the stream (e.g. NeuralWatt energy/cost telemetry). */
33
+ readonly onComment?: (text: string) => ProviderEvent | undefined;
12
34
  }
35
+ export interface OpenAIChatEventsOptions {
36
+ readonly signal?: AbortSignal;
37
+ /** Require `[DONE]` and a `finish_reason`; `done` then carries the final usage. */
38
+ readonly strictCompletion?: boolean;
39
+ /** Emit the final stream usage on the `done` event. */
40
+ readonly doneUsage?: boolean;
41
+ readonly mapUsage?: (usage: unknown) => Usage | undefined;
42
+ /** Handle an SSE comment line (text after `:`), e.g. NeuralWatt `: energy` / `: cost` telemetry. Returned events are yielded in stream order before the data of the same SSE event. */
43
+ readonly onComment?: (text: string) => ProviderEvent | undefined;
44
+ }
45
+ /**
46
+ * Shared OpenAI Chat Completions SSE stream loop: maps `data:` frames to Prism
47
+ * `ProviderEvent` values (text/thinking deltas, tool-call fragments, usage, done/error).
48
+ */
49
+ export declare function openAIChatEvents(body: ReadableStream<Uint8Array>, options?: OpenAIChatEventsOptions): AsyncIterable<ProviderEvent>;
13
50
  export declare function createOpenAICompatibleProvider(options: OpenAICompatibleProviderOptions): AIProvider;
51
+ /** Subset of factory options that shape the request body. */
52
+ export type OpenAIChatBodyOptions = Pick<OpenAICompatibleProviderOptions, "mapMessages" | "serializeMessage" | "buildBodyExtra" | "transformBody">;
53
+ /** Base Chat Completions request body builder, exported for provider packages keeping public body helpers. */
54
+ export declare function buildOpenAIChatBody(request: ProviderRequest, options?: OpenAIChatBodyOptions): JsonObject;
@@ -2,26 +2,109 @@ import { resolveCredentialValue } from "../credentials.js";
2
2
  import { providerDone, providerError, providerTextDelta, providerThinkingDelta, providerToolCall, providerToolCallDelta, providerUsage, toolCallFromArgumentsText, } from "../provider-events.js";
3
3
  import { assertStructuredOutputRequestSupported } from "../structured-output.js";
4
4
  import { applyOpenAIChatStructuredOutput, assertOpenAIChatMessage, mapOpenAIChatUsage, serializeOpenAIChatMessage, serializeOpenAITool, } from "./openai-primitives.js";
5
- import { ProviderTransportError, readBoundedResponseText, readSseData } from "./transport.js";
5
+ import { httpStatusError, ProviderTransportError, readBoundedResponseText, readSseEvents } from "./transport.js";
6
+ /**
7
+ * Shared OpenAI Chat Completions SSE stream loop: maps `data:` frames to Prism
8
+ * `ProviderEvent` values (text/thinking deltas, tool-call fragments, usage, done/error).
9
+ */
10
+ export async function* openAIChatEvents(body, options = {}) {
11
+ const tools = new Map();
12
+ let usage;
13
+ let sawDoneMarker = false;
14
+ let sawFinishReason = false;
15
+ for await (const sseEvent of readSseEvents(body, { signal: options.signal })) {
16
+ if (options.onComment && sseEvent.comments?.length) {
17
+ for (const text of sseEvent.comments) {
18
+ const commentEvent = options.onComment(text);
19
+ if (commentEvent)
20
+ yield commentEvent;
21
+ }
22
+ }
23
+ const data = sseEvent.data.trim();
24
+ if (!data)
25
+ continue;
26
+ if (data === "[DONE]") {
27
+ sawDoneMarker = true;
28
+ break;
29
+ }
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(data);
33
+ }
34
+ catch (error) {
35
+ // Malformed chunks are terminal: yield the error instead of crashing the generator.
36
+ yield providerError(error, []);
37
+ return;
38
+ }
39
+ const mapped = (options.mapUsage ?? mapOpenAIChatUsage)(parsed.usage);
40
+ if (mapped) {
41
+ usage = mapped;
42
+ yield providerUsage(mapped);
43
+ }
44
+ for (const choice of parsed.choices ?? []) {
45
+ if (choice.finish_reason)
46
+ sawFinishReason = true;
47
+ const delta = choice.delta ?? {};
48
+ if (typeof delta.content === "string" && delta.content)
49
+ yield providerTextDelta(delta.content);
50
+ const thinking = delta.reasoning ?? delta.reasoning_content;
51
+ if (typeof thinking === "string" && thinking) {
52
+ yield providerThinkingDelta(thinking);
53
+ }
54
+ for (const tool of delta.tool_calls ?? []) {
55
+ const index = tool.index ?? 0;
56
+ const current = tools.get(index) ?? { argumentsText: "" };
57
+ current.id = tool.id ?? current.id;
58
+ current.name = tool.function?.name ?? current.name;
59
+ current.argumentsText += tool.function?.arguments ?? "";
60
+ tools.set(index, current);
61
+ yield providerToolCallDelta({
62
+ index,
63
+ id: tool.id,
64
+ name: tool.function?.name,
65
+ argumentsText: tool.function?.arguments,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ const incomplete = [...tools.entries()].find(([, call]) => !call.id || !call.name);
71
+ if (incomplete) {
72
+ yield providerError(new ProviderTransportError("incomplete_delta", `Incomplete tool call delta at index ${incomplete[0]}`));
73
+ return;
74
+ }
75
+ if (options.strictCompletion && (!sawDoneMarker || !sawFinishReason)) {
76
+ // Truncated streams must fail loudly — emitting done would mark partial output as succeeded.
77
+ yield providerError(new Error(`Chat stream ended without completion evidence ` +
78
+ `([DONE]: ${sawDoneMarker ? "received" : "missing"}, ` +
79
+ `finish_reason: ${sawFinishReason ? "received" : "missing"})`));
80
+ return;
81
+ }
82
+ for (const call of tools.values()) {
83
+ yield providerToolCall(toolCallFromArgumentsText(call.id, call.name, call.argumentsText));
84
+ }
85
+ yield providerDone(options.strictCompletion || options.doneUsage ? usage : undefined);
86
+ }
6
87
  export function createOpenAICompatibleProvider(options) {
7
88
  const providerId = options.id ?? "openai-compatible";
8
89
  return {
9
90
  id: providerId,
10
91
  async *generate(request) {
92
+ if (request.signal?.aborted)
93
+ throw request.signal.reason ?? new Error("aborted");
11
94
  const apiKey = await resolveCredentialValue(options.apiKey, {
12
95
  name: "apiKey",
13
96
  provider: providerId,
14
97
  });
15
98
  const fetchImpl = options.fetch ?? fetch;
16
99
  const secrets = [apiKey];
17
- const tools = new Map();
18
100
  try {
19
101
  const url = typeof options.chatCompletionsUrl === "function"
20
102
  ? options.chatCompletionsUrl(request)
21
- : (options.chatCompletionsUrl ?? `${options.baseUrl.replace(/\/$/, "")}/chat/completions`);
103
+ : (options.chatCompletionsUrl ?? `${options.baseUrl.replace(/\/+$/, "")}/chat/completions`);
22
104
  const authStyle = options.authStyle ?? "bearer";
23
105
  const headers = {
24
106
  ...Object.fromEntries(Object.entries(request.options?.headers ?? {}).filter((entry) => typeof entry[1] === "string")),
107
+ ...options.extraHeaders?.(request),
25
108
  "content-type": "application/json",
26
109
  };
27
110
  if (apiKey && authStyle === "api-key")
@@ -31,56 +114,28 @@ export function createOpenAICompatibleProvider(options) {
31
114
  const response = await fetchImpl(url, {
32
115
  method: "POST",
33
116
  headers,
34
- body: JSON.stringify(toOpenAIRequest(request)),
117
+ body: JSON.stringify(toOpenAIRequest(request, options)),
35
118
  signal: request.signal,
36
119
  });
37
120
  if (!response.ok) {
38
- yield providerError(new Error(`OpenAI-compatible request failed: ${response.status} ${await readBoundedResponseText(response, { secrets })}`), secrets);
121
+ const bodyText = await readBoundedResponseText(response, { secrets });
122
+ const error = options.mapHttpError
123
+ ? options.mapHttpError(response, bodyText, secrets)
124
+ : httpStatusError(options.requestFailedPrefix ?? "OpenAI-compatible request failed", response, bodyText);
125
+ yield providerError(error, secrets);
39
126
  return;
40
127
  }
41
128
  if (!response.body) {
42
129
  yield providerError(new Error("OpenAI-compatible response had no body"), secrets);
43
130
  return;
44
131
  }
45
- for await (const data of readSseData(response.body, { signal: request.signal })) {
46
- if (data === "[DONE]")
47
- break;
48
- const parsed = JSON.parse(data);
49
- const usage = mapOpenAIChatUsage(parsed.usage);
50
- if (usage)
51
- yield providerUsage(usage);
52
- for (const choice of parsed.choices ?? []) {
53
- const delta = choice.delta ?? {};
54
- if (typeof delta.content === "string" && delta.content)
55
- yield providerTextDelta(delta.content);
56
- if (typeof delta.reasoning_content === "string" && delta.reasoning_content) {
57
- yield providerThinkingDelta(delta.reasoning_content);
58
- }
59
- for (const tool of delta.tool_calls ?? []) {
60
- const index = tool.index ?? 0;
61
- const current = tools.get(index) ?? { argumentsText: "" };
62
- current.id = tool.id ?? current.id;
63
- current.name = tool.function?.name ?? current.name;
64
- current.argumentsText += tool.function?.arguments ?? "";
65
- tools.set(index, current);
66
- yield providerToolCallDelta({
67
- index,
68
- id: tool.id,
69
- name: tool.function?.name,
70
- argumentsText: tool.function?.arguments,
71
- });
72
- }
73
- }
74
- }
75
- const incomplete = [...tools.entries()].find(([, call]) => !call.id || !call.name);
76
- if (incomplete) {
77
- yield providerError(new ProviderTransportError("incomplete_delta", `Incomplete tool call delta at index ${incomplete[0]}`), secrets);
78
- return;
79
- }
80
- for (const call of tools.values()) {
81
- yield providerToolCall(toolCallFromArgumentsText(call.id, call.name, call.argumentsText));
82
- }
83
- yield providerDone();
132
+ yield* openAIChatEvents(response.body, {
133
+ signal: request.signal,
134
+ strictCompletion: options.strictCompletion,
135
+ doneUsage: options.doneUsage,
136
+ mapUsage: options.mapUsage,
137
+ onComment: options.onComment,
138
+ });
84
139
  }
85
140
  catch (error) {
86
141
  yield providerError(error, secrets);
@@ -88,11 +143,13 @@ export function createOpenAICompatibleProvider(options) {
88
143
  },
89
144
  };
90
145
  }
91
- function toOpenAIRequest(request) {
146
+ function toOpenAIRequest(request, options) {
92
147
  assertStructuredOutputRequestSupported(request.model, request.options);
93
148
  const body = {
94
149
  model: request.model.model,
95
- messages: request.messages.map((message, index) => {
150
+ messages: (options.mapMessages?.(request) ?? request.messages).map((message, index) => {
151
+ if (options.serializeMessage)
152
+ return options.serializeMessage(message, request);
96
153
  assertOpenAIChatMessage(message, `messages[${index}]`);
97
154
  return serializeOpenAIChatMessage(message, request.model.capabilities ?? {});
98
155
  }),
@@ -102,6 +159,11 @@ function toOpenAIRequest(request) {
102
159
  ...request.model.parameters,
103
160
  };
104
161
  applyOpenAIChatStructuredOutput(body, request.options?.structuredOutput);
105
- return body;
162
+ const merged = { ...body, ...options.buildBodyExtra?.(request) };
163
+ return options.transformBody ? options.transformBody(merged, request) : merged;
164
+ }
165
+ /** Base Chat Completions request body builder, exported for provider packages keeping public body helpers. */
166
+ export function buildOpenAIChatBody(request, options = {}) {
167
+ return toOpenAIRequest(request, options);
106
168
  }
107
169
  //# sourceMappingURL=openai-compatible.js.map
@@ -20,6 +20,12 @@ export declare class ProviderTransportError extends Error {
20
20
  readonly limitBytes?: number;
21
21
  constructor(code: ProviderTransportErrorCode, message: string, limitBytes?: number);
22
22
  }
23
+ /** HTTP failure error carrying the status as numeric `code` plus any `Retry-After`
24
+ * hint as `retryAfterMs`, so retry policies classify transience and pace retries
25
+ * without parsing message text. Both fields flow into `ErrorInfo` via errorToErrorInfo. */
26
+ export declare function httpStatusError(prefix: string, response: Response, bodyText: string): Error;
27
+ /** Parse a `Retry-After` header (delay-seconds or HTTP-date) into milliseconds. */
28
+ export declare function parseRetryAfterMs(value: string | null, now?: number): number | undefined;
23
29
  export interface ReadSseEventsOptions extends BoundedStreamLimits {
24
30
  readonly signal?: AbortSignal;
25
31
  }
@@ -13,6 +13,27 @@ export class ProviderTransportError extends Error {
13
13
  this.limitBytes = limitBytes;
14
14
  }
15
15
  }
16
+ /** HTTP failure error carrying the status as numeric `code` plus any `Retry-After`
17
+ * hint as `retryAfterMs`, so retry policies classify transience and pace retries
18
+ * without parsing message text. Both fields flow into `ErrorInfo` via errorToErrorInfo. */
19
+ export function httpStatusError(prefix, response, bodyText) {
20
+ const error = new Error(`${prefix}: ${response.status} ${bodyText}`);
21
+ error.code = response.status;
22
+ const hint = parseRetryAfterMs(response.headers.get("retry-after"));
23
+ if (hint !== undefined)
24
+ error.retryAfterMs = hint;
25
+ return error;
26
+ }
27
+ /** Parse a `Retry-After` header (delay-seconds or HTTP-date) into milliseconds. */
28
+ export function parseRetryAfterMs(value, now = Date.now()) {
29
+ if (!value)
30
+ return undefined;
31
+ const seconds = Number(value);
32
+ if (Number.isFinite(seconds) && seconds >= 0)
33
+ return seconds * 1000;
34
+ const date = Date.parse(value);
35
+ return Number.isNaN(date) ? undefined : Math.max(0, date - now);
36
+ }
16
37
  function resolveLimits(options) {
17
38
  return {
18
39
  maxEventBytes: options?.maxEventBytes ?? DEFAULT_MAX_EVENT_BYTES,
@@ -2,6 +2,8 @@ import type { AIProvider, ModelConfig, ProviderResolver } from "./contracts.js";
2
2
  import { type DuplicateRegistrationOptions } from "./registry-options.js";
3
3
  export interface ProviderRegistry {
4
4
  register(provider: AIProvider): void;
5
+ /** Remove a provider; returns false when the id was not registered. */
6
+ unregister(id: string): boolean;
5
7
  get(id: string): AIProvider | undefined;
6
8
  resolve(model: Pick<ModelConfig, "provider"> | string): AIProvider;
7
9
  list(): readonly AIProvider[];
package/dist/providers.js CHANGED
@@ -6,6 +6,9 @@ export function createProviderRegistry(providers = [], options = {}) {
6
6
  assertCanRegister(byId, provider.id, "provider", provider.id, options.duplicate);
7
7
  byId.set(provider.id, provider);
8
8
  },
9
+ unregister(id) {
10
+ return byId.delete(id);
11
+ },
9
12
  get(id) {
10
13
  return byId.get(id);
11
14
  },
package/dist/redaction.js CHANGED
@@ -1,4 +1,7 @@
1
1
  const REDACTED = "[REDACTED]";
2
+ // Depth bound matching agent-run-state.ts; hostile deep structures yield a placeholder
3
+ // instead of a stack overflow.
4
+ const MAX_REDACT_DEPTH = 32;
2
5
  export function createSecretRedactor(secrets) {
3
6
  return { redact: (value) => redactSecrets(value, secrets) };
4
7
  }
@@ -43,11 +46,13 @@ export function redactSecrets(value, secrets) {
43
46
  // ponytail: active-path WeakSet marks only ancestor cycles as [Circular]; shared
44
47
  // references (diamonds) are visited again on separate branches. Map/Set normalize
45
48
  // to JSON-shaped output; string keys are redacted like values.
46
- const redact = (input, active = new WeakSet()) => {
49
+ const redact = (input, active = new WeakSet(), depth = 0) => {
47
50
  if (typeof input === "string")
48
51
  return redactString(input);
49
52
  if (input === null || typeof input !== "object")
50
53
  return input;
54
+ if (depth >= MAX_REDACT_DEPTH)
55
+ return "[MaxDepth]";
51
56
  if (input instanceof Date || input instanceof RegExp)
52
57
  return input;
53
58
  if (ArrayBuffer.isView(input) || input instanceof ArrayBuffer)
@@ -57,18 +62,18 @@ export function redactSecrets(value, secrets) {
57
62
  active.add(input);
58
63
  try {
59
64
  if (Array.isArray(input))
60
- return input.map((item) => redact(item, active));
65
+ return input.map((item) => redact(item, active, depth + 1));
61
66
  if (input instanceof Map) {
62
67
  const out = {};
63
68
  for (const [key, item] of input)
64
- assignKey(out, redactKey(key), redact(item, active));
69
+ assignKey(out, redactKey(key), redact(item, active, depth + 1));
65
70
  return out;
66
71
  }
67
72
  if (input instanceof Set)
68
- return [...input].map((item) => redact(item, active));
73
+ return [...input].map((item) => redact(item, active, depth + 1));
69
74
  const out = {};
70
75
  for (const [key, item] of Object.entries(input))
71
- assignKey(out, redactKey(key), redact(item, active));
76
+ assignKey(out, redactKey(key), redact(item, active, depth + 1));
72
77
  return out;
73
78
  }
74
79
  finally {
@@ -79,18 +84,21 @@ export function redactSecrets(value, secrets) {
79
84
  }
80
85
  export function errorToErrorInfo(error, secrets = []) {
81
86
  const code = readErrorCode(error);
87
+ const retry = readRetryAfterMs(error);
88
+ const retryAfter = retry !== undefined ? { retryAfterMs: retry } : {};
82
89
  if (error instanceof Error) {
83
90
  return {
84
91
  name: error.name,
85
92
  message: redactSecrets(error.message, secrets),
86
93
  code,
94
+ ...retryAfter,
87
95
  cause: error.cause ? redactSecrets(String(error.cause), secrets) : undefined,
88
96
  };
89
97
  }
90
98
  if (error && typeof error === "object" && "message" in error) {
91
- return { message: redactSecrets(String(error.message), secrets), code };
99
+ return { message: redactSecrets(String(error.message), secrets), code, ...retryAfter };
92
100
  }
93
- return { message: redactSecrets(String(error), secrets), code };
101
+ return { message: redactSecrets(String(error), secrets), code, ...retryAfter };
94
102
  }
95
103
  function readErrorCode(error) {
96
104
  if (!error || typeof error !== "object" || !("code" in error))
@@ -98,4 +106,10 @@ function readErrorCode(error) {
98
106
  const code = error.code;
99
107
  return typeof code === "string" || typeof code === "number" ? code : undefined;
100
108
  }
109
+ function readRetryAfterMs(error) {
110
+ if (!error || typeof error !== "object" || !("retryAfterMs" in error))
111
+ return undefined;
112
+ const value = error.retryAfterMs;
113
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
114
+ }
101
115
  //# sourceMappingURL=redaction.js.map
package/dist/retry.d.ts CHANGED
@@ -5,6 +5,11 @@ export interface DefaultRetryPolicyOptions {
5
5
  readonly baseDelayMs?: number;
6
6
  readonly maxDelayMs?: number;
7
7
  readonly transientCodes?: readonly (string | number)[];
8
+ /** Symmetric jitter fraction applied to computed delays (default 0.25 → ±25%).
9
+ * Prevents thundering-herd retries under a shared outage. Set 0 to disable. */
10
+ readonly jitter?: number;
11
+ /** Random source for jitter (tests); defaults to Math.random. */
12
+ readonly random?: () => number;
8
13
  }
9
14
  export declare function createDefaultRetryPolicy(options?: DefaultRetryPolicyOptions): RetryPolicy;
10
15
  export declare function isTransientErrorInfo(error: ErrorInfo, transientCodes?: ReadonlySet<string | number>): boolean;
package/dist/retry.js CHANGED
@@ -22,13 +22,20 @@ export function createDefaultRetryPolicy(options = {}) {
22
22
  const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
23
23
  const baseDelayMs = Math.max(0, options.baseDelayMs ?? 100);
24
24
  const maxDelayMs = Math.max(baseDelayMs, options.maxDelayMs ?? 1000);
25
+ const jitter = Math.min(1, Math.max(0, options.jitter ?? 0.25));
26
+ const random = options.random ?? Math.random;
25
27
  const transientCodes = new Set([...TRANSIENT_CODES, ...(options.transientCodes ?? [])]);
26
28
  return {
27
29
  name: options.name ?? "default-retry",
28
30
  decide(context) {
29
31
  if (context.signal?.aborted || context.attempt >= maxAttempts || !isTransientErrorInfo(context.error, transientCodes))
30
32
  return { retry: false };
31
- return { retry: true, delayMs: Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, context.attempt - 1)) };
33
+ // Provider backpressure hint (Retry-After) wins over computed backoff, always
34
+ // capped at maxDelayMs so a hostile/huge hint cannot pin a run.
35
+ const hint = context.error.retryAfterMs;
36
+ const base = Math.min(hint !== undefined && Number.isFinite(hint) && hint >= 0 ? hint : baseDelayMs * 2 ** Math.max(0, context.attempt - 1), maxDelayMs);
37
+ const delayMs = jitter > 0 ? Math.round(base * (1 - jitter + random() * 2 * jitter)) : base;
38
+ return { retry: true, delayMs };
32
39
  },
33
40
  };
34
41
  }
@@ -6,9 +6,15 @@ export declare const HARD_LEDGER_BATCH_BYTES: number;
6
6
  export declare const DEFAULT_LEDGER_BATCH_DELAY_MS = 25;
7
7
  export declare const HARD_LEDGER_BATCH_DELAY_MS = 60000;
8
8
  export interface BatchedRunLedgerOptions {
9
+ /** Flush triggers, not write grouping: a pending flush fires once the buffer reaches
10
+ * this many entries. Writes still go to the target one at a time (RunLedger has no
11
+ * batch append); these bounds shape flush *timing*, not per-write coalescing. */
9
12
  readonly maxBatchEntries?: number;
13
+ /** Flush trigger: pending flush fires once buffered bytes reach this bound. */
10
14
  readonly maxBatchBytes?: number;
15
+ /** Backpressure bound: enqueue flushes synchronously before exceeding this. */
11
16
  readonly maxBufferedEntries?: number;
17
+ /** Backpressure bound (bytes): enqueue flushes synchronously before exceeding this. */
12
18
  readonly maxBufferedBytes?: number;
13
19
  readonly maxDelayMs?: number;
14
20
  readonly durability?: RunLedgerDurability;
@@ -58,20 +58,14 @@ export function createBatchedRunLedger(target, options = {}) {
58
58
  const flush = () => {
59
59
  cancelTimer();
60
60
  const operation = flushChain.then(async () => {
61
- let entries = 0;
62
- let bytes = 0;
61
+ // One write per record — RunLedger has no batch append API. maxBatchEntries/
62
+ // maxBatchBytes only decide when a flush fires (see enqueue), never how writes group.
63
63
  while (queue.length) {
64
64
  const item = queue[0];
65
- if (entries && (entries >= maxBatchEntries || bytes + item.bytes > maxBatchBytes)) {
66
- entries = 0;
67
- bytes = 0;
68
- }
69
- await write(item);
65
+ await write(item); // shifts only after success — a failed write stays buffered for retry
70
66
  queue.shift();
71
67
  bufferedBytes -= item.bytes;
72
68
  flushed += 1;
73
- entries += 1;
74
- bytes += item.bytes;
75
69
  }
76
70
  return status();
77
71
  });