@jxsuite/ai 0.39.2 → 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.
@@ -57,18 +57,33 @@ type EventBody = HarnessEvent extends infer E
57
57
  : never
58
58
  : never;
59
59
 
60
+ /** True when a member name of a usage frame is one of the counts {@link Usage} names. */
61
+ function isUsageField(key: string): key is keyof Usage {
62
+ return (
63
+ key === "inputTokens" ||
64
+ key === "outputTokens" ||
65
+ key === "cachedInputTokens" ||
66
+ key === "reasoningTokens"
67
+ );
68
+ }
69
+
70
+ /** True when a count a backend's usage frame carries is a number, which the frame does not prove. */
71
+ function isCount(value: unknown): value is number {
72
+ return typeof value === "number";
73
+ }
74
+
60
75
  /** Two usage counts summed field by field; a field either one reports is reported. */
61
76
  function addUsage(total: Usage | null, next: Usage): Usage {
62
77
  if (total === null) {
63
78
  return { ...next };
64
79
  }
65
- const sum: Record<string, number> = { ...total };
80
+ const sum: Usage = { ...total };
66
81
  for (const [key, value] of Object.entries(next)) {
67
- if (typeof value === "number") {
82
+ if (isUsageField(key) && isCount(value)) {
68
83
  sum[key] = (sum[key] ?? 0) + value;
69
84
  }
70
85
  }
71
- return sum as unknown as Usage;
86
+ return sum;
72
87
  }
73
88
 
74
89
  /**
@@ -103,7 +118,8 @@ function createEventQueue() {
103
118
  },
104
119
  async *read(): AsyncGenerator<HarnessEvent> {
105
120
  let index = 0;
106
- while (true) {
121
+ // `push` and `close` change what this loop reads from outside it, so it exits from within.
122
+ for (;;) {
107
123
  if (index < events.length) {
108
124
  yield events[index]!;
109
125
  index += 1;
@@ -154,6 +170,9 @@ export function runTurn(input: TurnInput): TurnRun {
154
170
  };
155
171
  }
156
172
  const { signal } = controller;
173
+ /* Read through a call: the signal aborts from outside, between awaits, which narrowing on
174
+ `signal.aborted` cannot see. */
175
+ const aborted = (): boolean => signal.aborted;
157
176
  const settle = () => {
158
177
  unlink();
159
178
  release();
@@ -163,13 +182,10 @@ export function runTurn(input: TurnInput): TurnRun {
163
182
  const session = input.session ?? createSessionFacts();
164
183
  const ledger = input.ledger ?? createLedger();
165
184
  const sessionId = input.sessionId === undefined ? session.sessionId : input.sessionId;
166
- const actor: Actor = {
167
- id: `assistant:${sessionId ?? "local"}:${turnId}`,
168
- kind: "assistant",
169
- ...(input.modelInfo ? { model: input.modelInfo.model } : {}),
170
- sessionId,
171
- turnId,
172
- };
185
+ const actorId = `assistant:${sessionId ?? "local"}:${turnId}`;
186
+ const actor: Actor = input.modelInfo
187
+ ? { id: actorId, kind: "assistant", model: input.modelInfo.model, sessionId, turnId }
188
+ : { id: actorId, kind: "assistant", sessionId, turnId };
173
189
 
174
190
  const queue = createEventQueue();
175
191
  let seq = 0;
@@ -181,6 +197,8 @@ export function runTurn(input: TurnInput): TurnRun {
181
197
  return;
182
198
  }
183
199
  ended = body.type === "turn_end";
200
+ /* SAFETY: EventBody is HarnessEvent with exactly `v`, `seq` and `turnId` omitted, member by
201
+ member, and this adds those three back, so the result is the member `body` came from. */
184
202
  const event = { v: 1, seq, turnId, ...body } as HarnessEvent;
185
203
  seq += 1;
186
204
  input.onEvent?.(event);
@@ -320,10 +338,9 @@ export function runTurn(input: TurnInput): TurnRun {
320
338
 
321
339
  const truncated = !sawDone;
322
340
  if (errorMessage) {
323
- const error: TurnError = {
324
- message: errorMessage,
325
- ...(errorCode ? { code: errorCode } : {}),
326
- };
341
+ const error: TurnError = errorCode
342
+ ? { message: errorMessage, code: errorCode }
343
+ : { message: errorMessage };
327
344
  input.hooks?.onStreamError?.(error);
328
345
  emit({ type: "round_end", messageId, stopReason, truncated, dropped: true, error });
329
346
  return finish("error", { error, removedMessageId: messageId });
@@ -331,7 +348,7 @@ export function runTurn(input: TurnInput): TurnRun {
331
348
 
332
349
  /* A turn that has drawn nothing by the end of a round with no calls ends here. A stopped
333
350
  round is not empty: the author ended it. */
334
- const stopped = stopReason === "cancelled" || signal.aborted;
351
+ const stopped = stopReason === "cancelled" || aborted();
335
352
  const drawn = text !== "" || records.length > 0;
336
353
  if (calls.size === 0 && !stopped && !drawnBefore && anchor === null && !drawn) {
337
354
  emit({ type: "round_end", messageId, stopReason, truncated, dropped: true });
@@ -353,13 +370,15 @@ export function runTurn(input: TurnInput): TurnRun {
353
370
  for (const record of records) {
354
371
  blocks.push({ type: "tool_call", ...record });
355
372
  }
356
- appended.push({
373
+ const drawnMessage: ChatMessage = {
357
374
  id: messageId,
358
375
  role: "assistant",
359
376
  blocks,
360
377
  timestamp: now(),
361
- ...(truncated ? { meta: { incomplete: "truncated" as const } } : {}),
362
- });
378
+ };
379
+ appended.push(
380
+ truncated ? { ...drawnMessage, meta: { incomplete: "truncated" } } : drawnMessage,
381
+ );
363
382
  anchor = messageId;
364
383
  }
365
384
  if (calls.size === 0 || stopped) {
@@ -368,7 +387,7 @@ export function runTurn(input: TurnInput): TurnRun {
368
387
 
369
388
  let didWork = false;
370
389
  for (const [callId, call] of calls) {
371
- if (signal.aborted) {
390
+ if (aborted()) {
372
391
  return finish("cancelled");
373
392
  }
374
393
  emit({
@@ -437,7 +456,7 @@ export function runTurn(input: TurnInput): TurnRun {
437
456
  workRounds += 1;
438
457
  }
439
458
  // A Stop that landed during the round's last call opens no further round.
440
- if (signal.aborted) {
459
+ if (aborted()) {
441
460
  return finish("cancelled");
442
461
  }
443
462
  }
@@ -459,14 +478,14 @@ export function runTurn(input: TurnInput): TurnRun {
459
478
  }
460
479
 
461
480
  const outcome = loop().finally(settle);
462
- outcome.then(
463
- () => {
481
+ void (async () => {
482
+ try {
483
+ await outcome;
464
484
  queue.close();
465
- },
466
- (error: unknown) => {
485
+ } catch (error) {
467
486
  queue.close({ error });
468
- },
469
- );
487
+ }
488
+ })();
470
489
 
471
490
  return {
472
491
  turnId,
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  export type { UpstreamCacheHints } from "./cache-hints.js";
33
33
 
34
34
  export type {
35
+ AiCredentials,
35
36
  AnthropicStreamingClientOptions,
36
37
  OpenAIStreamingClientOptions,
37
38
  ProxyStreamingClientOptions,
@@ -20,25 +20,42 @@ import type {
20
20
  WireMessage,
21
21
  } from "./types.ts";
22
22
 
23
+ /**
24
+ * True when an unchecked value (a v1 field, a `tools` listing's member) is a string. A v1 field
25
+ * that is anything else is absent (see {@link blocksFromFields}).
26
+ */
27
+ export function isText(value: unknown): value is string {
28
+ return typeof value === "string";
29
+ }
30
+
31
+ /**
32
+ * A v1 tool call as a restored session may hold it: never checked, so any member may be missing or
33
+ * hold anything.
34
+ */
35
+ interface StoredToolCall {
36
+ readonly id?: unknown;
37
+ readonly name?: unknown;
38
+ readonly arguments?: unknown;
39
+ }
40
+
41
+ /**
42
+ * True when an unchecked value (a stored tool call, a parsed tool reply, a `tools` listing's
43
+ * member) is an object whose members may be read; null and every primitive are not.
44
+ */
45
+ export function isMemberBag(value: unknown): value is object {
46
+ return typeof value === "object" && value !== null;
47
+ }
48
+
23
49
  /** Whether a tool reply's content is a result that failed. Unparseable content is not. */
24
50
  function isErrorContent(content: string): boolean {
25
51
  try {
26
- const parsed = JSON.parse(content) as unknown;
27
- return (
28
- typeof parsed === "object" &&
29
- parsed !== null &&
30
- (parsed as { success?: unknown }).success === false
31
- );
52
+ const parsed: unknown = JSON.parse(content);
53
+ return isMemberBag(parsed) && "success" in parsed && parsed.success === false;
32
54
  } catch {
33
55
  return false;
34
56
  }
35
57
  }
36
58
 
37
- /** A v1 value read as text: a string is itself, and anything else is absent. */
38
- function asText(value: unknown): string {
39
- return typeof value === "string" ? value : "";
40
- }
41
-
42
59
  /**
43
60
  * The blocks a message's v1 fields describe, in the order a provider streams them.
44
61
  *
@@ -48,46 +65,81 @@ function asText(value: unknown): string {
48
65
  */
49
66
  function blocksFromFields(message: LiveMessage): Block[] {
50
67
  if (message.role === "tool") {
51
- const content = asText(message.content);
68
+ const content = isText(message.content) ? message.content : "";
52
69
  return [
53
70
  {
54
71
  type: "tool_result",
55
- callId: asText(message.toolCallId),
72
+ callId: isText(message.toolCallId) ? message.toolCallId : "",
56
73
  isError: isErrorContent(content),
57
74
  content,
58
75
  },
59
76
  ];
60
77
  }
61
78
  const blocks: Block[] = [];
62
- if (message.role === "assistant" && typeof message.reasoningContent === "string") {
79
+ if (message.role === "assistant" && isText(message.reasoningContent)) {
63
80
  blocks.push({ type: "reasoning", text: message.reasoningContent });
64
81
  }
65
- if (asText(message.content)) {
82
+ if (isText(message.content) && message.content) {
66
83
  blocks.push({ type: "text", text: message.content });
67
84
  }
68
85
  if (message.role === "assistant" && Array.isArray(message.toolCalls)) {
69
- for (const call of message.toolCalls as unknown[]) {
70
- if (typeof call !== "object" || call === null) {
86
+ // A restored session's calls are unchecked, so each is read as what it may really be.
87
+ const stored: readonly unknown[] = message.toolCalls;
88
+ for (const call of stored) {
89
+ if (!isMemberBag(call)) {
71
90
  continue;
72
91
  }
73
- const { id, name, arguments: args } = call as Record<string, unknown>;
92
+ // Every member of StoredToolCall is optional and `unknown`, so any object is one.
93
+ const { id, name, arguments: args }: StoredToolCall = call;
74
94
  blocks.push({
75
95
  type: "tool_call",
76
- id: asText(id),
77
- name: asText(name),
78
- argumentsText: asText(args),
96
+ id: isText(id) ? id : "",
97
+ name: isText(name) ? name : "",
98
+ argumentsText: isText(args) ? args : "",
79
99
  });
80
100
  }
81
101
  }
82
102
  return blocks;
83
103
  }
84
104
 
105
+ /**
106
+ * A value {@link sameJson} walks: JSON, in which an object member may also be `undefined` (an
107
+ * optional member left unset), which counts as absent.
108
+ */
109
+ type Walked = null | boolean | number | string | Walked[] | WalkedObject;
110
+
111
+ /** An object {@link sameJson} walks. */
112
+ interface WalkedObject {
113
+ readonly [member: string]: Walked | undefined;
114
+ }
115
+
116
+ /** True when a walked value is an array or an object rather than a primitive or `null`. */
117
+ function isComposite(value: Walked | undefined): value is Walked[] | WalkedObject {
118
+ return typeof value === "object" && value !== null;
119
+ }
120
+
121
+ /**
122
+ * A block as the JSON {@link sameJson} walks: a shallow copy, which carries every member (an
123
+ * undefined one included) and so compares exactly as the block itself would. A reasoning block's
124
+ * `provenance` is copied too, since it is the one member that is itself an object.
125
+ *
126
+ * @param {Block} block
127
+ * @returns {WalkedObject}
128
+ */
129
+ function walkedBlock(block: Block): WalkedObject {
130
+ if (block.type !== "reasoning") {
131
+ return { ...block };
132
+ }
133
+ const { provenance, ...rest } = block;
134
+ return provenance === undefined ? rest : { ...rest, provenance: { ...provenance } };
135
+ }
136
+
85
137
  /** Whether two JSON values are equal, member order aside. */
86
- function sameJson(a: unknown, b: unknown): boolean {
138
+ function sameJson(a: Walked | undefined, b: Walked | undefined): boolean {
87
139
  if (Object.is(a, b)) {
88
140
  return true;
89
141
  }
90
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
142
+ if (!isComposite(a) || !isComposite(b)) {
91
143
  return false;
92
144
  }
93
145
  if (Array.isArray(a) || Array.isArray(b)) {
@@ -100,10 +152,7 @@ function sameJson(a: unknown, b: unknown): boolean {
100
152
  }
101
153
  const left = Object.entries(a).filter(([, value]) => value !== undefined);
102
154
  const right = Object.entries(b).filter(([, value]) => value !== undefined);
103
- return (
104
- left.length === right.length &&
105
- left.every(([key, value]) => sameJson(value, (b as Record<string, unknown>)[key]))
106
- );
155
+ return left.length === right.length && left.every(([key, value]) => sameJson(value, b[key]));
107
156
  }
108
157
 
109
158
  /** The concatenated text of a message's blocks of one kind. */
@@ -131,6 +180,7 @@ export function isEmptyAssistant(message: ChatMessage | LiveMessage): boolean {
131
180
  if (message.role !== "assistant") {
132
181
  return false;
133
182
  }
183
+ // SAFETY: a ChatMessage always carries `blocks`, so a message without them is a LiveMessage.
134
184
  const blocks = message.blocks ?? blocksFromFields(message as LiveMessage);
135
185
  return !blocks.some(
136
186
  (block) => block.type === "tool_call" || (block.type === "text" && block.text !== ""),
@@ -145,13 +195,12 @@ export function isEmptyAssistant(message: ChatMessage | LiveMessage): boolean {
145
195
  * @returns {ChatMessage[]}
146
196
  */
147
197
  export function toChatMessages(live: readonly LiveMessage[]): ChatMessage[] {
148
- return live.map((message) => ({
149
- id: message.id,
150
- role: message.role,
151
- blocks: message.blocks ? [...message.blocks] : blocksFromFields(message),
152
- timestamp: message.timestamp,
153
- ...(message.meta ? { meta: message.meta } : {}),
154
- }));
198
+ return live.map((message): ChatMessage => {
199
+ const { id, role, timestamp, meta } = message;
200
+ const blocks = message.blocks ? [...message.blocks] : blocksFromFields(message);
201
+ const chat: ChatMessage = { id, role, blocks, timestamp };
202
+ return meta ? { ...chat, meta } : chat;
203
+ });
155
204
  }
156
205
 
157
206
  /**
@@ -192,7 +241,13 @@ export function toLiveMessages(messages: readonly ChatMessage[]): LiveMessage[]
192
241
  }));
193
242
  }
194
243
  }
195
- if (!sameJson(blocksFromFields(live), message.blocks)) {
244
+ const rebuilt = blocksFromFields(live).map((block) => walkedBlock(block));
245
+ if (
246
+ !sameJson(
247
+ rebuilt,
248
+ message.blocks.map((block) => walkedBlock(block)),
249
+ )
250
+ ) {
196
251
  live.blocks = [...message.blocks];
197
252
  }
198
253
  if (message.meta) {
@@ -210,7 +265,7 @@ export function toLiveMessages(messages: readonly ChatMessage[]): LiveMessage[]
210
265
  * @returns {WireMessage[]}
211
266
  */
212
267
  export function toWireMessages(messages: readonly ChatMessage[]): WireMessage[] {
213
- return messages
214
- .filter((message) => !isEmptyAssistant(message))
215
- .map((message) => ({ role: message.role, blocks: message.blocks }));
268
+ return messages.flatMap((message): WireMessage[] =>
269
+ isEmptyAssistant(message) ? [] : [{ role: message.role, blocks: message.blocks }],
270
+ );
216
271
  }
@@ -9,7 +9,8 @@
9
9
  * @license MIT
10
10
  */
11
11
 
12
- import { isEmptyAssistant } from "./convert.ts";
12
+ import type { JSONSchema } from "../tools.ts";
13
+ import { isEmptyAssistant, isMemberBag, isText } from "./convert.ts";
13
14
  import type {
14
15
  ChatMessage,
15
16
  OpenAIMessage,
@@ -113,15 +114,34 @@ export function toOpenAIMessages(messages: readonly ChatMessage[]): OpenAIMessag
113
114
  * @returns {OpenAITool[]}
114
115
  */
115
116
  export function toOpenAITools(tools: readonly ToolSpec[]): OpenAITool[] {
116
- return tools.map((tool) => ({
117
- type: "function",
118
- function: {
117
+ return tools.map((tool): OpenAITool => {
118
+ const fn: OpenAITool["function"] = {
119
119
  name: tool.name,
120
120
  description: tool.description,
121
121
  parameters: tool.inputSchema,
122
- ...(tool.strict === true ? { strict: true as const } : {}),
123
- },
124
- }));
122
+ };
123
+ if (tool.strict === true) {
124
+ fn.strict = true;
125
+ }
126
+ return { type: "function", function: fn };
127
+ });
128
+ }
129
+
130
+ /**
131
+ * A `tools` entry as {@link fromOpenAITools} reads it: any object, whose members are checked before
132
+ * they are used.
133
+ */
134
+ interface ListedTool {
135
+ readonly type?: unknown;
136
+ readonly function?: unknown;
137
+ }
138
+
139
+ /** A `tools` entry's `function` member, read the same way. */
140
+ interface ListedFunction {
141
+ readonly name?: unknown;
142
+ readonly description?: unknown;
143
+ readonly parameters?: unknown;
144
+ readonly strict?: unknown;
125
145
  }
126
146
 
127
147
  /**
@@ -133,28 +153,29 @@ export function toOpenAITools(tools: readonly ToolSpec[]): OpenAITool[] {
133
153
  * @throws {TypeError} When an entry is not a function tool with a name, a description and a schema
134
154
  */
135
155
  export function fromOpenAITools(tools: readonly object[]): ToolSpec[] {
136
- return tools.map((tool, index) => {
137
- const fn = (tool as { type?: unknown; function?: unknown }).function as
138
- | { name?: unknown; description?: unknown; parameters?: unknown; strict?: unknown }
139
- | undefined;
156
+ return tools.map((tool, index): ToolSpec => {
157
+ // Every member of ListedTool and ListedFunction is optional and `unknown`: any object is one.
158
+ const listed: ListedTool = tool;
159
+ const fn: ListedFunction | undefined = isMemberBag(listed.function)
160
+ ? listed.function
161
+ : undefined;
140
162
  if (
141
- (tool as { type?: unknown }).type !== "function" ||
142
- typeof fn !== "object" ||
143
- fn === null ||
144
- typeof fn.name !== "string" ||
145
- typeof fn.description !== "string" ||
146
- typeof fn.parameters !== "object" ||
147
- fn.parameters === null
163
+ listed.type !== "function" ||
164
+ fn === undefined ||
165
+ !isText(fn.name) ||
166
+ !isText(fn.description) ||
167
+ !isMemberBag(fn.parameters)
148
168
  ) {
149
169
  throw new TypeError(
150
170
  `tools[${index}] is not an OpenAI function tool with a name, a description and parameters`,
151
171
  );
152
172
  }
153
- return {
154
- name: fn.name,
155
- description: fn.description,
156
- inputSchema: fn.parameters as Readonly<Record<string, unknown>>,
157
- ...(fn.strict === true ? { strict: true } : {}),
158
- };
173
+ /* SAFETY: `parameters` was just checked to be a non-null object, and it is kept by reference
174
+ and never read here: it is the schema the listing's registry was given as a JSONSchema. */
175
+ const inputSchema = fn.parameters as Readonly<JSONSchema>;
176
+ if (fn.strict === true) {
177
+ return { name: fn.name, description: fn.description, inputSchema, strict: true };
178
+ }
179
+ return { name: fn.name, description: fn.description, inputSchema };
159
180
  });
160
181
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import type { JsonValue, ToolResult } from "../core-types.ts";
20
+ import type { JSONSchema } from "../tools.ts";
20
21
 
21
22
  export type { JsonArray, JsonObject, JsonValue } from "../core-types.ts";
22
23
 
@@ -167,7 +168,7 @@ export interface ToolSpec {
167
168
  readonly title?: string;
168
169
  readonly description: string;
169
170
  /** JSON Schema for the arguments, by reference: a host may build it with live getters. */
170
- readonly inputSchema: Readonly<Record<string, unknown>>;
171
+ readonly inputSchema: Readonly<JSONSchema>;
171
172
  /** OpenAI's strict mode, opted into per tool. */
172
173
  readonly strict?: boolean;
173
174
  }
@@ -196,7 +197,7 @@ export interface OpenAITool {
196
197
  function: {
197
198
  name: string;
198
199
  description: string;
199
- parameters: Readonly<Record<string, unknown>>;
200
+ parameters: Readonly<JSONSchema>;
200
201
  strict?: true;
201
202
  };
202
203
  }