@jxsuite/ai 0.39.3 → 0.39.4

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
@@ -4,13 +4,13 @@ The provider-agnostic substrate Jx Studio's assistant is built on: a streaming L
4
4
 
5
5
  Governing spec: [`specs/ai.md`](../../specs/ai.md) §1-§2 (Status: Partial). User-facing documentation for the Studio feature lives at [`docs/studio/ai.md`](../../docs/studio/ai.md).
6
6
 
7
- | Entrypoint | Exports |
8
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
- | `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, the `ChatUsage` type, `STREAM_EVENT_TYPES`, the streaming types, and the prompt-cache hints (below) |
10
- | `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its eight members, the three factories and their option types, `STREAM_EVENT_TYPES`, `usageEventFromOpenAI` and its `OpenAIUsage` input type, and the prompt-cache hints |
11
- | `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, the call context (`createToolContext`, `createLedger`, `createSessionFacts`, `linkCallSignal`), and their types |
12
- | `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `ChatUsage`, `Message`, `ToolCallRecord`, `ChatState`, `MessageRole` |
13
- | `@jxsuite/ai/gateway` | `createChatHandler`, `modelsResponse`, `normalizeOpenAIStream`, `encodeSse`, `problemResponse`, `extractUpstreamErrorMessage`, the prompt-cache hints, and the `Upstream`, `GatewayRefusal`, `ChatAdmission` and `ChatGatewayOptions` types |
7
+ | Entrypoint | Exports |
8
+ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, the `ChatUsage` type, `STREAM_EVENT_TYPES`, the streaming types, and the prompt-cache hints (below) |
10
+ | `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its eight members, the three factories and their option types, `STREAM_EVENT_TYPES`, `usageEventFromOpenAI` and its `OpenAIUsage` input type, and the prompt-cache hints |
11
+ | `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, the call context (`createToolContext`, `createLedger`, `createSessionFacts`, `linkCallSignal`), and their types |
12
+ | `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `ChatUsage`, `ChatState`; the messages it holds are `LiveMessage`, `LiveToolCall` and `Role` from `@jxsuite/ai/messages`, which this subpath also exports under their former names `Message`, `ToolCallRecord` and `MessageRole` |
13
+ | `@jxsuite/ai/gateway` | `createChatHandler`, `DEFAULT_UPSTREAM_BASE_URL` (the upstream it targets when none is configured), `modelsResponse`, `normalizeOpenAIStream`, `encodeSse`, `problemResponse`, `extractUpstreamErrorMessage`, the prompt-cache hints, and the `Upstream`, `GatewayRefusal`, `ChatAdmission` and `ChatGatewayOptions` types |
14
14
 
15
15
  `toolSuccess` / `toolError` are **not** re-exported by the barrel. Import them from `@jxsuite/ai/tools`. Importing the subpaths instead of the root is also what makes the package tree-shakeable.
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.39.3",
3
+ "version": "0.39.4",
4
4
  "description": "AI infrastructure for Jx Suite — streaming LLM client abstraction, tool registry, and reactive chat state",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,11 +31,11 @@
31
31
  "upgrade": "bun update --latest && bun install"
32
32
  },
33
33
  "dependencies": {
34
- "@jxsuite/protocol": "^2.6.1",
35
- "@vue/reactivity": "3.5.43"
34
+ "@jxsuite/protocol": "^3.0.0",
35
+ "@vue/reactivity": "3.5.44"
36
36
  },
37
37
  "devDependencies": {
38
- "@cloudflare/workers-types": "^5.20261002.1",
39
- "@happy-dom/global-registrator": "^20.14.5"
38
+ "@cloudflare/workers-types": "^5.20261016.1",
39
+ "@happy-dom/global-registrator": "^21.0.3"
40
40
  }
41
41
  }
package/src/chat-state.ts CHANGED
@@ -14,19 +14,21 @@ import { reactive } from "@vue/reactivity";
14
14
  import { nextMessageId } from "./message-id.ts";
15
15
  import { toChatMessages } from "./messages/convert.ts";
16
16
  import { toOpenAIMessages } from "./messages/openai.ts";
17
- import type { LiveMessage, LiveToolCall, OpenAIMessage, Role } from "./messages/types.ts";
17
+ import type { LiveMessage, LiveToolCall, OpenAIMessage } from "./messages/types.ts";
18
18
  import type { StreamUsageEvent } from "./streaming-client.ts";
19
19
  import type { ToolResult } from "./tools.ts";
20
20
 
21
21
  export type ChatState = "idle" | "streaming" | "error";
22
22
 
23
- export type MessageRole = Role;
24
-
25
- /** A transcript message: the v1 shape `@jxsuite/ai/messages` reads (specs/ai.md §2.3). */
26
- export type Message = LiveMessage;
27
-
28
- /** A tool call on a transcript message, carrying the outcome its chip renders. */
29
- export type ToolCallRecord = LiveToolCall;
23
+ /**
24
+ * The published `@jxsuite/ai/chat-state` names for the transcript types, kept for existing
25
+ * importers.
26
+ */
27
+ export type {
28
+ LiveMessage as Message,
29
+ LiveToolCall as ToolCallRecord,
30
+ Role as MessageRole,
31
+ } from "./messages/types.ts";
30
32
 
31
33
  /**
32
34
  * The provider's own count for the last request, and where in the transcript it was taken.
@@ -57,10 +59,10 @@ export interface ChatUsage {
57
59
  }
58
60
 
59
61
  export interface ChatStore {
60
- messages: Message[];
62
+ messages: LiveMessage[];
61
63
  status: ChatState;
62
64
  streamingContent: string;
63
- pendingToolCalls: ToolCallRecord[];
65
+ pendingToolCalls: LiveToolCall[];
64
66
  error: string | null;
65
67
  model: string;
66
68
  tokenCount: number;
@@ -126,7 +128,7 @@ export function createChatState(opts: { model?: string } = {}) {
126
128
  usage: null,
127
129
  });
128
130
 
129
- let _streamingMessage: Message | null = null;
131
+ let _streamingMessage: LiveMessage | null = null;
130
132
 
131
133
  /**
132
134
  * Add a user message and prepare for streaming response.
@@ -138,12 +140,12 @@ export function createChatState(opts: { model?: string } = {}) {
138
140
  return;
139
141
  }
140
142
 
141
- const userMsg = {
143
+ const userMsg: LiveMessage = {
142
144
  id: uid(),
143
145
  role: "user",
144
146
  content: text,
145
147
  timestamp: Date.now(),
146
- } as Message;
148
+ };
147
149
  store.messages.push(userMsg);
148
150
 
149
151
  beginAssistantTurn();
@@ -172,7 +174,7 @@ export function createChatState(opts: { model?: string } = {}) {
172
174
  role: "assistant",
173
175
  content: "",
174
176
  timestamp: Date.now(),
175
- } as Message;
177
+ };
176
178
  store.messages.push(_streamingMessage);
177
179
  /*
178
180
  * Re-read through the reactive proxy so appendDelta / appendToolCallStart mutations notify
@@ -196,7 +198,7 @@ export function createChatState(opts: { model?: string } = {}) {
196
198
  toolCallId,
197
199
  content,
198
200
  timestamp: Date.now(),
199
- } as Message);
201
+ });
200
202
  }
201
203
 
202
204
  /**
@@ -237,12 +239,12 @@ export function createChatState(opts: { model?: string } = {}) {
237
239
  if (store.status !== "streaming") {
238
240
  return;
239
241
  }
240
- const tc = {
242
+ const tc: LiveToolCall = {
241
243
  id,
242
244
  name,
243
245
  arguments: "",
244
246
  result: null,
245
- } as ToolCallRecord;
247
+ };
246
248
  store.pendingToolCalls.push(tc);
247
249
 
248
250
  if (_streamingMessage) {
@@ -423,13 +425,16 @@ export function createChatState(opts: { model?: string } = {}) {
423
425
  const replayed = sent?.role === "assistant" && Boolean(sent.reasoning_content);
424
426
  const unreplayed = replayed || !isCount(usage.reasoningTokens) ? 0 : usage.reasoningTokens;
425
427
  const contextTokens = Math.max(0, usage.inputTokens + usage.outputTokens - unreplayed);
426
- store.usage = {
428
+ const recorded: ChatUsage = {
427
429
  ...counts,
428
430
  contextTokens,
429
431
  lastMessageId: store.messages.at(-1)?.id ?? "",
430
432
  messageCount: store.messages.length,
431
- ...(extra.systemTokens === undefined ? {} : { systemTokens: extra.systemTokens }),
432
433
  };
434
+ if (extra.systemTokens !== undefined) {
435
+ recorded.systemTokens = extra.systemTokens;
436
+ }
437
+ store.usage = recorded;
433
438
  store.tokenCount = contextTokens;
434
439
  }
435
440
 
@@ -11,6 +11,15 @@ import { problemResponse, refusal } from "./problem.ts";
11
11
  import { encodeSse, SSE_HEADERS } from "./sse.ts";
12
12
  import type { ChatGatewayOptions, GatewayRefusal, Upstream } from "./types.ts";
13
13
 
14
+ /**
15
+ * The upstream a host forwards to when it configures none: OpenAI's own chat-completions API.
16
+ *
17
+ * The gateway never applies it itself (which upstream a request may reach is host policy, supplied
18
+ * through `resolveUpstream`). It lives here so every host that falls back to OpenAI, the dev server
19
+ * and the assistant's eval runner among them, names the same URL.
20
+ */
21
+ export const DEFAULT_UPSTREAM_BASE_URL = "https://api.openai.com/v1";
22
+
14
23
  /** The refusal's detail for a body that is not JSON. */
15
24
  const INVALID_JSON = "Invalid JSON body";
16
25
 
@@ -160,6 +169,10 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
160
169
  JSON, as it was when the server read it with Bun's `req.json()`. The default decoder would
161
170
  strip it and accept the body; this extraction changes no byte a client receives. */
162
171
  const text = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(bytes);
172
+ /* SAFETY: every member of ChatRequestBody is optional and `unknown`, so the type claims only
173
+ that the parsed value can be destructured; each member is checked (messages, tools) or
174
+ forwarded as `unknown` (model, systemPrompt) below, and a JSON `null` throws at the
175
+ destructuring, as the route always has. */
163
176
  body = JSON.parse(text) as ChatRequestBody;
164
177
  } catch {
165
178
  return problemResponse(refusal("invalidRequest", INVALID_JSON));
@@ -192,13 +205,16 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
192
205
  context,
193
206
  );
194
207
 
208
+ // SAFETY: Array.isArray above proved `messages` is an array; its entries are forwarded unread.
195
209
  const upstreamBody: UpstreamBody = {
196
210
  model,
197
211
  messages: [{ role: "system", content: systemPrompt }, ...(messages as unknown[])],
198
212
  stream: true,
199
213
  stream_options: { include_usage: true },
200
214
  };
201
- // The wire has always forwarded `tools` as sent, so a non-array with a length passes through.
215
+ /* The wire has always forwarded `tools` as sent, so a non-array with a length passes through.
216
+ SAFETY: only `length` is read, off a truthy value, and that read is total: a value with no
217
+ numeric `length` yields `undefined` (or its own member), which `> 0` compares as it always has. */
202
218
  if (tools && (tools as { length: number }).length > 0) {
203
219
  upstreamBody.tools = tools;
204
220
  upstreamBody.tool_choice = "auto";
@@ -23,7 +23,7 @@ export {
23
23
  isAiSessionId,
24
24
  upstreamCacheHints,
25
25
  } from "../cache-hints.ts";
26
- export { createChatHandler } from "./chat.ts";
26
+ export { createChatHandler, DEFAULT_UPSTREAM_BASE_URL } from "./chat.ts";
27
27
  export { modelsResponse } from "./models.ts";
28
28
  export { normalizeOpenAIStream } from "./normalize.ts";
29
29
  export { problemResponse } from "./problem.ts";
@@ -11,41 +11,10 @@
11
11
  */
12
12
 
13
13
  import { problemDetails } from "@jxsuite/protocol/problems";
14
- import type { StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
14
+ import type { OpenAIStreamChunk, StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
15
15
  import { problemTypeForStatus } from "./problem.ts";
16
16
  import { extractUpstreamErrorMessage } from "./upstream-error.ts";
17
17
 
18
- /** A tool-call fragment inside an OpenAI streaming `delta`. */
19
- interface ToolCallDelta {
20
- index: number;
21
- id?: string;
22
- type?: string;
23
- function?: { name?: string; arguments?: string };
24
- }
25
-
26
- /** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
27
- interface OpenAIStreamChunk {
28
- choices?: {
29
- index?: number;
30
- delta?: {
31
- content?: string;
32
- /** Thinking models' chain-of-thought: DeepSeek and Volcengine spell it this way... */
33
- reasoning_content?: string;
34
- /** ...and OpenRouter this way. Both carry the same thing. */
35
- reasoning?: string;
36
- tool_calls?: ToolCallDelta[];
37
- };
38
- finish_reason?: string | null;
39
- }[];
40
- /** The count `stream_options.include_usage` asks for, normally a final chunk with no choices. */
41
- usage?: {
42
- prompt_tokens?: number;
43
- completion_tokens?: number;
44
- prompt_tokens_details?: { cached_tokens?: number } | null;
45
- completion_tokens_details?: { reasoning_tokens?: number } | null;
46
- } | null;
47
- }
48
-
49
18
  /**
50
19
  * Normalize an upstream usage object into a `usage` frame, or null when it carries no count. The
51
20
  * detail figures are included only when the provider sent them: absent is "not said", not zero.
@@ -54,7 +23,7 @@ interface OpenAIStreamChunk {
54
23
  * @returns {StreamUsageEvent | null}
55
24
  */
56
25
  function usageFrame(usage: OpenAIStreamChunk["usage"]): StreamUsageEvent | null {
57
- if (!usage || typeof usage.prompt_tokens !== "number") {
26
+ if (!usage || !isCount(usage.prompt_tokens)) {
58
27
  return null;
59
28
  }
60
29
  const frame: StreamUsageEvent = {
@@ -63,16 +32,37 @@ function usageFrame(usage: OpenAIStreamChunk["usage"]): StreamUsageEvent | null
63
32
  outputTokens: usage.completion_tokens ?? 0,
64
33
  };
65
34
  const cached = usage.prompt_tokens_details?.cached_tokens;
66
- if (typeof cached === "number") {
35
+ if (isCount(cached)) {
67
36
  frame.cachedInputTokens = cached;
68
37
  }
69
38
  const reasoning = usage.completion_tokens_details?.reasoning_tokens;
70
- if (typeof reasoning === "number") {
39
+ if (isCount(reasoning)) {
71
40
  frame.reasoningTokens = reasoning;
72
41
  }
73
42
  return frame;
74
43
  }
75
44
 
45
+ /**
46
+ * True when a usage figure the provider sent is a number. The chunk is upstream JSON, so a figure
47
+ * its interface calls a number is checked before it is reported.
48
+ *
49
+ * @param {unknown} value - A `usage` member, as the provider sent it
50
+ * @returns {boolean}
51
+ */
52
+ function isCount(value: unknown): value is number {
53
+ return typeof value === "number";
54
+ }
55
+
56
+ /**
57
+ * True when a chain-of-thought member the provider sent is a string.
58
+ *
59
+ * @param {unknown} value - `reasoning_content` or `reasoning`, as the provider sent it
60
+ * @returns {boolean}
61
+ */
62
+ function isText(value: unknown): value is string {
63
+ return typeof value === "string";
64
+ }
65
+
76
66
  /**
77
67
  * The stop reasons the stream reports; any other `finish_reason` keeps it reading.
78
68
  *
@@ -94,14 +84,31 @@ function stopReasonOf(reason: string | null | undefined): string | null {
94
84
  * failed, so nothing can change the status now. `message` stays for the readers that already show
95
85
  * it (server.md §4.3).
96
86
  *
97
- * @param {unknown} error - What `fetch` rejected with
87
+ * @param {unknown} cause - What `fetch` rejected with
88
+ * @returns {StreamEvent}
89
+ */
90
+ export function fetchFailureFrame(cause: unknown): StreamEvent {
91
+ return failureFrame("Network error", cause);
92
+ }
93
+
94
+ /**
95
+ * The frame for an exchange that failed: `done: cancelled` for an abort, otherwise an `error` frame
96
+ * whose `message` is `prefix` and the failure's own message.
97
+ *
98
+ * @param {"Network error" | "Stream error"} prefix - Which half of the exchange failed
99
+ * @param {unknown} cause - What the request or the read rejected with
98
100
  * @returns {StreamEvent}
99
101
  */
100
- export function fetchFailureFrame(error: unknown): StreamEvent {
101
- if ((error as Error).name === "AbortError") {
102
+ function failureFrame(prefix: "Network error" | "Stream error", cause: unknown): StreamEvent {
103
+ /* SAFETY: `fetch` and a body reader reject with an Error (a TypeError, or the abort's
104
+ AbortError DOMException), and the reads below throw only TypeErrors. Only `name` and `message`
105
+ are read, so a custom non-null abort reason that is not an Error reads both as undefined, as
106
+ it always has. */
107
+ const failure = cause as Error;
108
+ if (failure.name === "AbortError") {
102
109
  return { type: "done", stopReason: "cancelled" };
103
110
  }
104
- const message = `Network error: ${(error as Error).message}`;
111
+ const message = `${prefix}: ${failure.message}`;
105
112
  return { message, problem: problemDetails("upstreamFailure", message), type: "error" };
106
113
  }
107
114
 
@@ -191,13 +198,8 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
191
198
  `finally` keeps that: the upstream must not outlive the reader it streams to. */
192
199
  let settled = false;
193
200
  try {
194
- while (true) {
195
- const { done, value } = await reader.read();
196
- if (done) {
197
- break;
198
- }
199
-
200
- buffer += decoder.decode(value, { stream: true });
201
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
202
+ buffer += decoder.decode(chunk.value, { stream: true });
201
203
  const lines = buffer.split("\n");
202
204
  buffer = lines.pop() || "";
203
205
 
@@ -216,6 +218,10 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
216
218
 
217
219
  let parsed: OpenAIStreamChunk;
218
220
  try {
221
+ /* SAFETY: the interface is the provider's wire shape, and this loop only reads it: each
222
+ member passes optional chaining, a truthiness test or a predicate before it shapes a
223
+ frame, and a chunk too malformed to read (JSON `null`, a non-iterable `tool_calls`)
224
+ throws into the catch below, which reports it as a stream error. */
219
225
  parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
220
226
  } catch {
221
227
  continue;
@@ -241,7 +247,7 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
241
247
  request carrying `tools`, so a proxy that swallows these frames makes the NEXT round a
242
248
  400 the client cannot repair. */
243
249
  const reasoning = delta?.reasoning_content ?? delta?.reasoning;
244
- if (typeof reasoning === "string" && reasoning) {
250
+ if (isText(reasoning) && reasoning) {
245
251
  yield { type: "reasoning", content: reasoning };
246
252
  }
247
253
 
@@ -288,12 +294,7 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
288
294
  } catch (error) {
289
295
  settled = true;
290
296
  void reader.cancel();
291
- if ((error as Error).name === "AbortError") {
292
- yield { type: "done", stopReason: "cancelled" };
293
- return;
294
- }
295
- const message = `Stream error: ${(error as Error).message}`;
296
- yield { message, problem: problemDetails("upstreamFailure", message), type: "error" };
297
+ yield failureFrame("Stream error", error);
297
298
  } finally {
298
299
  if (!settled) {
299
300
  void reader.cancel();
@@ -8,8 +8,18 @@
8
8
  import { PROBLEM_MEDIA_TYPE } from "@jxsuite/protocol/problem";
9
9
  import { problemDetails } from "@jxsuite/protocol/problems";
10
10
  import type { ProblemTypeName } from "@jxsuite/protocol/problems";
11
+ import type { ProblemDetails } from "@jxsuite/protocol/problem";
11
12
  import type { GatewayRefusal } from "./types.ts";
12
13
 
14
+ /**
15
+ * A problem body as an unmigrated client reads it: `error` is the line it shows. The gateway takes
16
+ * this view only to ask whether that line is already there, which is a writer's question, not a
17
+ * reader's, so the deprecation on `ProblemDetails.error` does not address it.
18
+ */
19
+ interface LegacyProblemView {
20
+ readonly error?: string;
21
+ }
22
+
13
23
  /**
14
24
  * A refusal of one declared problem type, answered with that type's own status.
15
25
  *
@@ -35,13 +45,15 @@ export function refusal(name: ProblemTypeName, detail: string): GatewayRefusal {
35
45
  */
36
46
  export function problemResponse(refused: GatewayRefusal): Response {
37
47
  const { code, problem, status } = refused;
38
- const body = {
39
- ...problem,
40
- ...(problem.error === undefined && problem.detail !== undefined
41
- ? { error: problem.detail }
42
- : {}),
43
- ...(code === undefined ? {} : { code }),
44
- };
48
+ const body: ProblemDetails = { ...problem };
49
+ const legacy: LegacyProblemView = problem;
50
+ if (legacy.error === undefined && problem.detail !== undefined) {
51
+ // The alias is written untyped, as `problemDetails` writes it, so no reader is taught to use it.
52
+ Object.assign(body, { error: problem.detail });
53
+ }
54
+ if (code !== undefined) {
55
+ body.code = code;
56
+ }
45
57
  // Response.json would send `application/json`; RFC 9457 §3 is what says this body is a problem.
46
58
  return Response.json(body, { headers: { "Content-Type": PROBLEM_MEDIA_TYPE }, status });
47
59
  }
@@ -61,20 +73,20 @@ export function problemResponse(refused: GatewayRefusal): Response {
61
73
  export function problemTypeForStatus(status: number): ProblemTypeName {
62
74
  /* Any other status still becomes SOME type, and which side of 500 it falls on is what decides
63
75
  whether the caller is being told it made a mistake or that the upstream did. */
64
- return STATUS_TYPES[status] ?? (status >= 500 ? "internalError" : "invalidRequest");
76
+ return STATUS_TYPES.get(status) ?? (status >= 500 ? "internalError" : "invalidRequest");
65
77
  }
66
78
 
67
79
  /** The statuses the table distinguishes, each onto a type answered with that same status. */
68
- const STATUS_TYPES: Readonly<Record<number, ProblemTypeName>> = {
69
- 400: "invalidRequest",
70
- 401: "unauthorized",
71
- 403: "forbidden",
72
- 404: "notFound",
73
- 405: "methodNotAllowed",
74
- 409: "conflict",
75
- 413: "payloadTooLarge",
76
- 501: "capabilityUnavailable",
77
- 502: "upstreamFailure",
78
- 503: "upstreamFailure",
79
- 504: "upstreamFailure",
80
- };
80
+ const STATUS_TYPES: ReadonlyMap<number, ProblemTypeName> = new Map<number, ProblemTypeName>([
81
+ [400, "invalidRequest"],
82
+ [401, "unauthorized"],
83
+ [403, "forbidden"],
84
+ [404, "notFound"],
85
+ [405, "methodNotAllowed"],
86
+ [409, "conflict"],
87
+ [413, "payloadTooLarge"],
88
+ [501, "capabilityUnavailable"],
89
+ [502, "upstreamFailure"],
90
+ [503, "upstreamFailure"],
91
+ [504, "upstreamFailure"],
92
+ ]);
@@ -10,11 +10,11 @@ import type { StreamEvent } from "../streaming-client.ts";
10
10
  * The headers a chat stream is answered with. `no-cache` because a replayed stream is a different
11
11
  * turn, and `keep-alive` because the answer is long-lived by design.
12
12
  */
13
- export const SSE_HEADERS: Readonly<Record<string, string>> = {
13
+ export const SSE_HEADERS = {
14
14
  "Content-Type": "text/event-stream",
15
15
  "Cache-Control": "no-cache",
16
16
  Connection: "keep-alive",
17
- };
17
+ } as const satisfies Readonly<Record<string, string>>;
18
18
 
19
19
  /**
20
20
  * Frame `events` as a Server-Sent Events body: `data: <JSON>` and a blank line per frame, and
@@ -26,29 +26,61 @@ export function extractUpstreamErrorMessage(rawBody: string, fallback: string):
26
26
  if (!rawBody) {
27
27
  return fallback;
28
28
  }
29
+ let parsed: unknown;
29
30
  try {
30
- const { error, errors } = JSON.parse(rawBody) as {
31
- error?: string | { message?: string };
32
- errors?: { code?: number; message?: string }[];
33
- };
34
- if (typeof error === "string") {
35
- return error;
36
- }
37
- if (error) {
38
- const { message } = error;
39
- if (message) {
40
- return message;
31
+ parsed = JSON.parse(rawBody);
32
+ } catch {
33
+ /* Not JSON: use the raw body. */
34
+ return rawBody;
35
+ }
36
+ // JSON `null`, a primitive or an array carries neither member, so it is the raw body.
37
+ if (isMemberBag(parsed)) {
38
+ if ("error" in parsed) {
39
+ const { error } = parsed;
40
+ if (isText(error)) {
41
+ return error;
42
+ }
43
+ if (isMemberBag(error) && "message" in error && Boolean(error.message)) {
44
+ return String(error.message);
41
45
  }
42
46
  }
43
- const [first] = errors ?? [];
44
- if (first) {
45
- const { message } = first;
46
- if (message) {
47
- return message;
47
+ if ("errors" in parsed && isEntryList(parsed.errors)) {
48
+ const [first] = parsed.errors;
49
+ if (isMemberBag(first) && "message" in first && Boolean(first.message)) {
50
+ return String(first.message);
48
51
  }
49
52
  }
50
- } catch {
51
- /* Not JSON: use the raw body. */
52
53
  }
53
54
  return rawBody;
54
55
  }
56
+
57
+ /**
58
+ * True when a parsed body is a string, empty or not: a flat `error` string is the answer as sent.
59
+ *
60
+ * @param {unknown} value - A member of the parsed body
61
+ * @returns {boolean}
62
+ */
63
+ function isText(value: unknown): value is string {
64
+ return typeof value === "string";
65
+ }
66
+
67
+ /**
68
+ * True when a parsed `errors` member is an array, whose first entry is Cloudflare's sentence.
69
+ *
70
+ * @param {unknown} value - The parsed body's `errors` member
71
+ * @returns {boolean}
72
+ */
73
+ function isEntryList(value: unknown): value is readonly unknown[] {
74
+ return Array.isArray(value);
75
+ }
76
+
77
+ /**
78
+ * True when a parsed value is a JSON object (or array) whose members may be probed with `in`; false
79
+ * for `null` and every primitive.
80
+ *
81
+ * @param {unknown} value - The parsed body, or one of its members
82
+ * @returns {boolean}
83
+ */
84
+ function isMemberBag(value: unknown): value is object {
85
+ return typeof value === "object" && value !== null;
86
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  export { LaneBusyError, createTurnLock } from "./lock.ts";
17
17
  export { fromStreamingClient, joinSystem } from "./model.ts";
18
- export { DEFAULT_TURN_POLICY } from "./policy.ts";
18
+ export { CHARS_PER_TOKEN, DEFAULT_TURN_POLICY } from "./policy.ts";
19
19
  export { runTurn } from "./run.ts";
20
20
 
21
21
  export type {
@@ -8,7 +8,7 @@
8
8
  import type { TurnPolicy } from "./types.ts";
9
9
 
10
10
  /** Average characters per token: the estimate every budget used before providers reported counts. */
11
- const CHARS_PER_TOKEN = 4;
11
+ export const CHARS_PER_TOKEN = 4;
12
12
 
13
13
  /**
14
14
  * The defaults: five rounds of work, twenty-five rounds in all, and the texts the agent loop has