@cubicecho/agent-core 4.17.0 → 4.18.0

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
@@ -213,6 +213,11 @@ is worth another attempt. The content-free `{"role":"assistant"}` most servers o
213
213
  does not set it: nothing has been shown to anybody yet, so an endpoint that primes the stream and
214
214
  then wedges is retried like one that never answered at all.
215
215
 
216
+ `inFlight` is the other box `streamTurn` takes, for what an attempt had said once it has thrown:
217
+ each call sets its `read` as it starts, so after a retry it reads the attempt that failed last and
218
+ nothing of the one before. `runTurn` and `continueTurn` pass it through, the second with the
219
+ answer it is continuing joined on, and `runAgentLoop` reads it into a failure's `partial`.
220
+
216
221
  `idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is still
217
222
  talking is never cut off however long it takes, and one that has stopped answering raises
218
223
  `EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
@@ -946,7 +951,7 @@ try {
946
951
  store.add(sessionId, turn?.reasoning ? { ...message, reasoning_content: turn.reasoning } : message),
947
952
  });
948
953
  } catch (error) {
949
- const run = failedRun(error); // messages, usage, toolCalls, loaded, used, preselected — or undefined
954
+ const run = failedRun(error); // messages, partial, usage, toolCalls, loaded, used, preselected — or undefined
950
955
  if (run) await store.keep(sessionId, { usage: run.usage, loaded: run.loaded });
951
956
  if (signal.aborted) return; // a stop, told by the signal as it always was
952
957
  if (error instanceof ContextOverflow) return compactAndRetry(run?.messages);
@@ -973,9 +978,14 @@ A failure while a step's tools are running leaves a transcript that can be sent
973
978
  that came back are kept, a call the run was stopped during is answered `Stopped before this call
974
979
  finished.`, and one the loop never reached `Not run: the run stopped first.` — a call with no
975
980
  result is a transcript an endpoint refuses. Those are announced through `onMessage` like any other
976
- result. The reply of a request that was in flight when the run stopped is not kept: the host has
977
- the deltas from its `output` and `thinking` events, and whether half an answer belongs in the
978
- history is its call.
981
+ result. The reply of a request that was in flight when the run stopped or failed — a stop, the
982
+ endpoint going silent, a stream that broke after its first token — is not in `messages`, because it
983
+ is not a turn an endpoint will take back. It is on the failure as `partial`: its `content`, its
984
+ `reasoning` and the `toolCalls` it had begun, assembled as far as they had arrived, with a
985
+ continuation's piece joined onto the answer it was continuing and, after a retry, only the attempt
986
+ that was in flight. A request that had said nothing leaves no `partial`, and neither does a
987
+ failure while the tools run, since that step's reply is already in `messages`. Whether half an
988
+ answer belongs in the history is the host's call.
979
989
 
980
990
  `resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
981
991
  consumer states and a library guessing it could send one where it was not meant to go. The rule
package/dist/index.d.ts CHANGED
@@ -37,7 +37,7 @@ export { type RunTurnOptions, runTurn } from './turn/run-turn.ts';
37
37
  export { type AskJsonOptions, ask, askJson, askReply, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, type SideTaskReply, taskCall, tryAsk, } from './turn/side-task.ts';
38
38
  export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.ts';
39
39
  export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.ts';
40
- export { CacheBreakReason, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
40
+ export { CacheBreakReason, type InFlight, type PartialTurn, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
41
41
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, SPLIT_OUTPUT, SPLIT_REASONING, type Split, stripThinking, THINK_FENCE, } from './wire/thinking.ts';
42
42
  export { type ContextBreakdown, type ContextBreakdownOptions, compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, type TokenEstimateOptions, } from './wire/tokens.ts';
43
43
  export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, type EndedBy, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ServerLogEntry, ServerLogLevel, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
@@ -577,7 +577,8 @@ export interface AgentLoopResult {
577
577
  * @remarks
578
578
  * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded`, `used`
579
579
  * and `preselected` — because the steps before a failure were real and a host that stores its
580
- * transcript has nothing else to store them from. A spent budget is a `ToolIterationLimit`, an
580
+ * transcript has nothing else to store them from. What the request in flight had said is on it
581
+ * as `partial`, and not in `messages`. A spent budget is a `ToolIterationLimit`, an
581
582
  * overflow is an `AgentLoopOverflow`, which is still a `ContextOverflow`, and anything else is an
582
583
  * `AgentLoopError` with what was caught as its `cause` and its message as its own; `failedRun`
583
584
  * reads the run off any of the three. A failure while a step's tools run leaves the transcript
@@ -77,7 +77,8 @@ function cacheDiagnosis(previous, next, usage) {
77
77
  * @remarks
78
78
  * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded`, `used`
79
79
  * and `preselected` — because the steps before a failure were real and a host that stores its
80
- * transcript has nothing else to store them from. A spent budget is a `ToolIterationLimit`, an
80
+ * transcript has nothing else to store them from. What the request in flight had said is on it
81
+ * as `partial`, and not in `messages`. A spent budget is a `ToolIterationLimit`, an
81
82
  * overflow is an `AgentLoopOverflow`, which is still a `ContextOverflow`, and anything else is an
82
83
  * `AgentLoopError` with what was caught as its `cause` and its message as its own; `failedRun`
83
84
  * reads the run off any of the three. A failure while a step's tools run leaves the transcript
@@ -630,16 +631,23 @@ async function runSteps(options, standing, pin) {
630
631
  let usage = noUsage();
631
632
  const toolCalls = [];
632
633
  const artifacts = new Map();
633
- standing.read = () => ({
634
- messages,
635
- usage,
636
- toolCalls,
637
- artifacts: [...artifacts.values()],
638
- // As the result would have said it: proxied, nothing is loaded that the history does not hold.
639
- loaded: proxied ? [] : [...loaded],
640
- used: [...used],
641
- preselected: [...preselected],
642
- });
634
+ // A box per request, emptied once its turn is in `messages`: a failure while the tools run had
635
+ // no request in flight.
636
+ let inFlight = {};
637
+ standing.read = () => {
638
+ const partial = inFlight.read?.();
639
+ return {
640
+ messages,
641
+ ...(partial ? { partial } : {}),
642
+ usage,
643
+ toolCalls,
644
+ artifacts: [...artifacts.values()],
645
+ // As the result would have said it: proxied, nothing is loaded that the history does not hold.
646
+ loaded: proxied ? [] : [...loaded],
647
+ used: [...used],
648
+ preselected: [...preselected],
649
+ };
650
+ };
643
651
  const announce = announcer(onMessage);
644
652
  // Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
645
653
  // moves the question without losing it — and one that summarises the question away takes the
@@ -764,8 +772,10 @@ async function runSteps(options, standing, pin) {
764
772
  ...withContext(messages, questionAt(), gathered.context, preface),
765
773
  ];
766
774
  const build = (supported, refused) => buildBody(sending, supported, refused, request, declared, toolOrder);
775
+ inFlight = {};
767
776
  const turnOptions = {
768
777
  model: config.model,
778
+ inFlight,
769
779
  droppable: Object.keys(extraBody ?? {}),
770
780
  ...(pin ? { headers: pin.headers } : {}),
771
781
  maxRetries,
@@ -819,6 +829,7 @@ async function runSteps(options, standing, pin) {
819
829
  const turn = maxContinuations > 0
820
830
  ? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
821
831
  : first;
832
+ inFlight = {};
822
833
  previous = {
823
834
  messages: request,
824
835
  tools: names,
@@ -62,6 +62,8 @@ export declare const isContinuable: (turn: Turn) => boolean;
62
62
  * server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
63
63
  * that check is only as good as a restart being word for word. Either way the answer so far is
64
64
  * kept, with a notice. So is it when the continuation fails any other way, since the tokens
65
- * already in hand are worth more than the error; only a stop is thrown.
65
+ * already in hand are worth more than the error; only a stop is thrown. An `inFlight` box reads
66
+ * the answer so far with the continuation's piece joined on, so a stop mid-continuation keeps it
67
+ * all.
66
68
  */
67
69
  export declare function continueTurn(client: OpenAI, supports: Capabilities, request: RequestBuilder, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
@@ -97,6 +97,22 @@ function joinUsage(first, next) {
97
97
  }
98
98
  return joined;
99
99
  }
100
+ /**
101
+ * The answer so far with the piece a continuation in flight had added to it.
102
+ *
103
+ * @param sofar - The turn being continued, which may itself be a joined one. Not written to.
104
+ * @param [piece] - What the continuation had said. Absent adds nothing.
105
+ * @returns The two joined as `continueTurn` joins turns: content and reasoning in order, and the
106
+ * continuation's own calls. `reasoning` and `toolCalls` are left out where empty.
107
+ */
108
+ function joinPartial(sofar, piece) {
109
+ const reasoning = sofar.reasoning + (piece?.reasoning ?? '');
110
+ return {
111
+ content: sofar.content + (piece?.content ?? ''),
112
+ ...(reasoning ? { reasoning } : {}),
113
+ ...(piece?.toolCalls ? { toolCalls: piece.toolCalls } : {}),
114
+ };
115
+ }
100
116
  /**
101
117
  * Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
102
118
  * so far as a trailing assistant message, and joins the pieces into one turn.
@@ -124,17 +140,28 @@ function joinUsage(first, next) {
124
140
  * server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
125
141
  * that check is only as good as a restart being word for word. Either way the answer so far is
126
142
  * kept, with a notice. So is it when the continuation fails any other way, since the tokens
127
- * already in hand are worth more than the error; only a stop is thrown.
143
+ * already in hand are worth more than the error; only a stop is thrown. An `inFlight` box reads
144
+ * the answer so far with the continuation's piece joined on, so a stop mid-continuation keeps it
145
+ * all.
128
146
  */
129
147
  export async function continueTurn(client, supports, request, turn, { maxContinuations = 1, ...options } = {}) {
130
148
  const refused = options.model === undefined ? undefined : modelCapabilitiesFor(supports, options.model);
131
149
  const who = modelLabel(options.model);
150
+ // The caller's box reads the whole answer, not only the piece the continuation streams.
151
+ const outer = options.inFlight;
152
+ const piece = {};
153
+ const told = outer ? { inFlight: piece } : {};
132
154
  let joined = turn;
133
155
  for (let count = 0; count < maxContinuations && isContinuable(joined); count++) {
134
156
  if (refused?.assistantPrefill === false) {
135
157
  break;
136
158
  }
137
159
  const answer = joined.content;
160
+ if (outer) {
161
+ const sofar = joined;
162
+ piece.read = undefined;
163
+ outer.read = () => joinPartial(sofar, piece.read?.());
164
+ }
138
165
  let next;
139
166
  try {
140
167
  next = await runTurn(client, supports, (capabilities, forModel) => {
@@ -143,7 +170,7 @@ export async function continueTurn(client, supports, request, turn, { maxContinu
143
170
  ...body,
144
171
  messages: [...body.messages, { role: Role.Assistant, content: answer }],
145
172
  };
146
- }, { ...options, startInReasoning: false });
173
+ }, { ...options, ...told, startInReasoning: false });
147
174
  }
148
175
  catch (error) {
149
176
  if (options.signal?.aborted) {
@@ -1,6 +1,6 @@
1
1
  import type OpenAI from 'openai';
2
2
  import { ContextOverflow } from './retry.ts';
3
- import type { TurnUsage } from './stream.ts';
3
+ import type { PartialTurn, TurnUsage } from './stream.ts';
4
4
  import type { RunArtifact, ToolCallOutcome } from './tool-calls.ts';
5
5
  /**
6
6
  * What went wrong, as a string.
@@ -27,9 +27,19 @@ export interface AgentLoopFailure {
27
27
  /**
28
28
  * The transcript, as the result's `messages` would have been: every assistant turn and tool
29
29
  * result the run added, and no system prompt. Well-formed — a call the run was stopped during
30
- * has a result saying so. The reply of a request that was in flight is not in it.
30
+ * has a result saying so. The reply of a request that was in flight is not in it: see `partial`.
31
31
  */
32
32
  messages: OpenAI.ChatCompletionMessageParam[];
33
+ /**
34
+ * What the request in flight had said when the run stopped or failed — a stop, the endpoint
35
+ * going silent, a stream that broke — continuations joined on. Absent where no request was in
36
+ * flight or it had said nothing.
37
+ *
38
+ * @remarks
39
+ * Not in `messages`, because it is not a turn an endpoint will take back. It is what a person
40
+ * watching had been shown, and whether it belongs in the history is the host's call.
41
+ */
42
+ partial?: PartialTurn;
33
43
  /** Summed over the turns that came back. A request that failed or was stopped adds nothing. */
34
44
  usage: TurnUsage;
35
45
  /** Every call that was made, in order. One the run was stopped during is `ok: false`. */
@@ -59,6 +69,7 @@ export interface AgentLoopFailure {
59
69
  export declare class AgentLoopError extends Error implements AgentLoopFailure {
60
70
  readonly name: string;
61
71
  readonly messages: OpenAI.ChatCompletionMessageParam[];
72
+ readonly partial?: PartialTurn;
62
73
  readonly usage: TurnUsage;
63
74
  readonly toolCalls: ToolCallOutcome[];
64
75
  readonly artifacts: RunArtifact[];
@@ -93,6 +104,7 @@ export declare class ToolIterationLimit extends AgentLoopError {
93
104
  */
94
105
  export declare class AgentLoopOverflow extends ContextOverflow implements AgentLoopFailure {
95
106
  readonly messages: OpenAI.ChatCompletionMessageParam[];
107
+ readonly partial?: PartialTurn;
96
108
  readonly usage: TurnUsage;
97
109
  readonly toolCalls: ToolCallOutcome[];
98
110
  readonly artifacts: RunArtifact[];
@@ -24,6 +24,7 @@ export const errorMessage = (error) => (error instanceof Error ? error.message :
24
24
  export class AgentLoopError extends Error {
25
25
  name = 'AgentLoopError';
26
26
  messages;
27
+ partial;
27
28
  usage;
28
29
  toolCalls;
29
30
  artifacts;
@@ -38,6 +39,9 @@ export class AgentLoopError extends Error {
38
39
  constructor(message, run, options) {
39
40
  super(message, options);
40
41
  this.messages = run.messages;
42
+ if (run.partial) {
43
+ this.partial = run.partial;
44
+ }
41
45
  this.usage = run.usage;
42
46
  this.toolCalls = run.toolCalls;
43
47
  this.artifacts = run.artifacts ?? [];
@@ -67,6 +71,7 @@ export class ToolIterationLimit extends AgentLoopError {
67
71
  */
68
72
  export class AgentLoopOverflow extends ContextOverflow {
69
73
  messages;
74
+ partial;
70
75
  usage;
71
76
  toolCalls;
72
77
  artifacts;
@@ -81,6 +86,9 @@ export class AgentLoopOverflow extends ContextOverflow {
81
86
  constructor(message, run, options) {
82
87
  super(message, options);
83
88
  this.messages = run.messages;
89
+ if (run.partial) {
90
+ this.partial = run.partial;
91
+ }
84
92
  this.usage = run.usage;
85
93
  this.toolCalls = run.toolCalls;
86
94
  this.artifacts = run.artifacts ?? [];
@@ -213,6 +213,39 @@ export interface Turn {
213
213
  export interface Produced {
214
214
  any: boolean;
215
215
  }
216
+ /**
217
+ * What a turn had said when it ended without coming back: the answer, the scratchpad and the calls,
218
+ * as far as each had arrived.
219
+ *
220
+ * @remarks
221
+ * It is not a `Turn`, and not a message an endpoint will take back: it has no usage, no reason it
222
+ * ended, and a call in it may stop mid-JSON. It is what a person watching had been shown, for a
223
+ * host to keep or to show.
224
+ */
225
+ export interface PartialTurn {
226
+ /** The answer so far, fences taken out; `""` where it had not begun. */
227
+ content: string;
228
+ /** The scratchpad so far, from its own field or a fence. Absent where none had arrived. */
229
+ reasoning?: string;
230
+ /**
231
+ * The calls begun, assembled as `Turn.toolCalls` would have been — a name may be missing and
232
+ * arguments may stop mid-JSON. Absent where none had begun.
233
+ */
234
+ toolCalls?: ToolCall[];
235
+ }
236
+ /**
237
+ * Where the request in flight can be read from, after it has thrown.
238
+ *
239
+ * @remarks
240
+ * A box for the reason `Produced` is one: a rejected promise carries no stream state, and the
241
+ * error is often the signal's reason, which is shared by every run under it. Each `streamTurn`
242
+ * sets `read` as it starts, so after a retry or a downgrade it reads the latest attempt and
243
+ * nothing of the one before.
244
+ */
245
+ export interface InFlight {
246
+ /** What the stream had said, or `undefined` where it had said nothing. */
247
+ read?: () => PartialTurn | undefined;
248
+ }
216
249
  /** What `streamTurn` takes besides the request body. */
217
250
  export interface StreamTurnOptions {
218
251
  /** Cancels the request and the stream being read from it. */
@@ -235,6 +268,11 @@ export interface StreamTurnOptions {
235
268
  firstChunkMs?: number;
236
269
  /** Set by the first chunk that carries anything, so a failed call knows if it can be retried. */
237
270
  produced?: Produced;
271
+ /**
272
+ * Given a `read` of what this call has said so far, as the call starts. Read it once the call has
273
+ * thrown; a turn that came back has it all, and the box is not cleared.
274
+ */
275
+ inFlight?: InFlight;
238
276
  /**
239
277
  * The fences that mark a scratchpad written into `content`.
240
278
  *
@@ -282,4 +320,4 @@ export interface StreamTurnOptions {
282
320
  * which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
283
321
  * line at the call site.
284
322
  */
285
- export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, fences, startInReasoning, onThinking, onOutput, headers, }?: StreamTurnOptions): Promise<Turn>;
323
+ export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, inFlight, fences, startInReasoning, onThinking, onOutput, headers, }?: StreamTurnOptions): Promise<Turn>;
@@ -188,6 +188,25 @@ function assembledCalls(calls) {
188
188
  }
189
189
  /** The largest delay a timer takes, which is as close to none as the SDK's timeout option goes. */
190
190
  const NO_SDK_TIMEOUT = 2_147_483_647;
191
+ /**
192
+ * A partial turn from what a stream had put together, or nothing where it had put nothing.
193
+ *
194
+ * @param content - The answer so far.
195
+ * @param reasoning - The scratchpad so far.
196
+ * @param calls - The calls begun, as they arrived. Not written to.
197
+ * @returns `undefined` where all three are empty; otherwise the content, with `reasoning` and
198
+ * `toolCalls` only where they hold something.
199
+ */
200
+ function partialTurn(content, reasoning, calls) {
201
+ if (content === '' && reasoning === '' && calls.length === 0) {
202
+ return undefined;
203
+ }
204
+ return {
205
+ content,
206
+ ...(reasoning ? { reasoning } : {}),
207
+ ...(calls.length ? { toolCalls: assembledCalls(calls) } : {}),
208
+ };
209
+ }
191
210
  /**
192
211
  * Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
193
212
  * message.
@@ -208,7 +227,7 @@ const NO_SDK_TIMEOUT = 2_147_483_647;
208
227
  * which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
209
228
  * line at the call site.
210
229
  */
211
- export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, headers, } = {}) {
230
+ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, inFlight, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, headers, } = {}) {
212
231
  // Silence, not duration: the timer is rearmed on every chunk, so a model that is still
213
232
  // talking is never cut off however long it takes, and one that has stopped talking does not
214
233
  // hang the run until someone notices. A request that never answers at all is the same case
@@ -229,6 +248,10 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
229
248
  idle = setTimeout(() => watchdog.abort(), ms);
230
249
  }
231
250
  };
251
+ if (inFlight) {
252
+ // Before the request, so a box an earlier attempt filled reads nothing of it.
253
+ inFlight.read = () => undefined;
254
+ }
232
255
  try {
233
256
  rearm(false);
234
257
  const started = Date.now();
@@ -271,6 +294,11 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
271
294
  // In arrival order, sorted by index at the end; a call from a server that sent none keeps
272
295
  // its place in the order they arrived.
273
296
  const calls = [];
297
+ if (inFlight) {
298
+ // Lazy, so a stream nobody stops pays nothing for it. Text held back as a possible tag is
299
+ // left out: it is a few characters that may yet be a marker.
300
+ inFlight.read = () => partialTurn(splitter.output, reasoning.join('') + splitter.reasoning, calls);
301
+ }
274
302
  const usage = noUsage();
275
303
  // The usage block's own cache count wins over llama.cpp's, which is the same number in the
276
304
  // servers that send both; kept apart so the order the two arrive in does not decide.
package/llms.txt CHANGED
@@ -387,6 +387,8 @@ Everything about a request failing that is not about what the request said.
387
387
  Reading one streamed turn back into a message.
388
388
 
389
389
  - `CacheBreakReason` — Any one of the reasons in `CacheBreakReason`.
390
+ - `InFlight` (type) — Where the request in flight can be read from, after it has thrown.
391
+ - `PartialTurn` (type) — What a turn had said when it ended without coming back: the answer, the scratchpad and the calls, as far as each had arrived.
390
392
  - `Produced` (type) — Whether the model has said anything a second attempt would say twice.
391
393
  - `StreamTurnOptions` (type) — What `streamTurn` takes besides the request body.
392
394
  - `streamTurn` — Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.17.0",
3
+ "version": "4.18.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",