@dbx-tools/appkit-model-gateway 0.9.190 → 0.9.194

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/src/gateway.ts CHANGED
@@ -9,10 +9,12 @@ import { log } from "@dbx-tools/shared-core";
9
9
  import {
10
10
  ModelClass,
11
11
  ModelClassSchema,
12
+ type ModelSource,
12
13
  type ServingEndpointSummary,
13
14
  type ModelClass as ModelClassType,
14
15
  } from "@dbx-tools/shared-model";
15
16
  import type {
17
+ AiDecideObservation,
16
18
  ClientProtocol,
17
19
  GatewayRoute,
18
20
  ModelCapabilityOverride,
@@ -21,6 +23,11 @@ import type {
21
23
  SmartRouterMode,
22
24
  } from "@dbx-tools/shared-model-gateway";
23
25
  import {
26
+ AI_DECIDE_CONFIDENCE_HEADER,
27
+ AI_DECIDE_MODEL_ID,
28
+ AI_DECIDE_SELECTED_MODEL_HEADER,
29
+ AI_DECIDE_STRATEGY_HEADER,
30
+ AiDecideStrategySchema,
24
31
  MODEL_GATEWAY_DEFAULTS,
25
32
  SMART_ROUTER_HARNESS_HEADER,
26
33
  SMART_ROUTER_MODEL_ID,
@@ -30,9 +37,11 @@ import {
30
37
  SMART_ROUTER_SERVED_TIER_HEADER,
31
38
  SMART_ROUTER_SOURCE_HEADER,
32
39
  SMART_ROUTER_TASK_TIER_HEADER,
40
+ isAiDecideModelId,
33
41
  isSmartRouterModelId,
34
42
  } from "@dbx-tools/shared-model-gateway";
35
43
 
44
+ import { AiDecideRouterService } from "./ai-decide.ts";
36
45
  import { listModelsPayload } from "./models.ts";
37
46
  import { adaptInferenceReasoning, learnAndAdaptReasoningRetry } from "./reasoning-adapt.ts";
38
47
  import {
@@ -64,6 +73,7 @@ export interface ModelGatewayOptions {
64
73
  readonly smartRouterTimeoutMs?: number;
65
74
  readonly smartRouterService?: SmartRouterService;
66
75
  readonly smartRouterStore?: SmartRouterStore;
76
+ readonly aiDecideService?: AiDecideRouterService;
67
77
  readonly overrides?: readonly ModelCapabilityOverride[];
68
78
  readonly model?: string;
69
79
  readonly modelClass?: ModelClassType;
@@ -85,6 +95,7 @@ export class ModelGatewayError extends Error {
85
95
  export class ModelGateway {
86
96
  private readonly registry: ModelRegistry;
87
97
  private readonly smartRouter?: SmartModelRouter;
98
+ private readonly aiDecide: AiDecideRouterService;
88
99
  private readonly smartRouterMode: SmartRouterMode;
89
100
  private readonly model?: string;
90
101
  private readonly modelClass?: ModelClassType;
@@ -99,6 +110,7 @@ export class ModelGateway {
99
110
  : {}),
100
111
  };
101
112
  this.registry = registry ?? new DatabricksModelRegistry(registryOptions);
113
+ this.aiDecide = options.aiDecideService ?? new AiDecideRouterService();
102
114
  this.smartRouterMode = options.smartRouter ?? MODEL_GATEWAY_DEFAULTS.smartRouter;
103
115
  if (this.smartRouterMode !== "false") {
104
116
  this.smartRouter = new SmartModelRouter(
@@ -126,22 +138,43 @@ export class ModelGateway {
126
138
  }
127
139
 
128
140
  /** Return the live OpenAI model list, including Codex metadata when requested. */
129
- async models(originator?: string, search?: string): Promise<ModelListResponse> {
141
+ async models(
142
+ originator?: string,
143
+ search?: string,
144
+ modelSource: ModelSource | "all" = "foundation",
145
+ includeMetadata = false,
146
+ ): Promise<ModelListResponse> {
130
147
  const codex = isCodexOriginator(originator);
131
148
  logger.debug("listing models", { codex, search });
132
149
  const query = search?.trim();
133
150
  const smartRouterSearch =
134
151
  query !== undefined && SMART_ROUTER_MODEL_ID.includes(query.toLowerCase());
152
+ const aiDecideSearch = query !== undefined && AI_DECIDE_MODEL_ID.includes(query.toLowerCase());
153
+ const discovered =
154
+ query && !smartRouterSearch && !aiDecideSearch
155
+ ? await this.registry.search(query)
156
+ : await this.registry.list();
135
157
  const targets =
136
- query && !smartRouterSearch ? await this.registry.search(query) : await this.registry.list();
158
+ modelSource === "all"
159
+ ? discovered
160
+ : discovered.filter((target) => target.endpoint?.modelSource === modelSource);
137
161
  const smartRouterTargets =
138
162
  this.smartRouterMode === "true" && query && !smartRouterSearch
139
- ? await this.registry.list()
163
+ ? (await this.registry.list()).filter(
164
+ (target) => modelSource === "all" || target.endpoint?.modelSource === modelSource,
165
+ )
140
166
  : targets;
141
167
  const shouldProbeSmartRouter =
142
168
  Boolean(this.smartRouter) && (this.smartRouterMode === "true" || !query || smartRouterSearch);
143
169
  const smartRouterEnabled =
144
170
  shouldProbeSmartRouter && (await this.smartRouter!.isEnabled(smartRouterTargets));
171
+ const aiDecideEnabled =
172
+ (!query || aiDecideSearch) &&
173
+ (await this.aiDecide.isEnabled(
174
+ targets
175
+ .map((target) => target.endpoint)
176
+ .filter((endpoint): endpoint is ServingEndpointSummary => endpoint !== undefined),
177
+ ));
145
178
  if (this.smartRouterMode === "true" && !smartRouterEnabled) {
146
179
  throw new ModelGatewayError(
147
180
  503,
@@ -155,8 +188,12 @@ export class ModelGateway {
155
188
  query !== undefined && query.length > 0,
156
189
  this.preferSystemModels,
157
190
  smartRouterEnabled && (!query || smartRouterSearch),
191
+ aiDecideEnabled && (!query || aiDecideSearch),
192
+ includeMetadata,
158
193
  );
159
- return smartRouterSearch ? onlySmartRouter(payload) : payload;
194
+ return smartRouterSearch || aiDecideSearch
195
+ ? onlyVirtualModel(payload, smartRouterSearch ? SMART_ROUTER_MODEL_ID : AI_DECIDE_MODEL_ID)
196
+ : payload;
160
197
  }
161
198
 
162
199
  /** Resolve and execute one inference request. */
@@ -178,6 +215,7 @@ export class ModelGateway {
178
215
  );
179
216
  logger.debug("resolving request", {
180
217
  smartRouter: selection.smartRouter === true,
218
+ aiDecide: selection.aiDecide === true,
181
219
  clientProtocol: protocol,
182
220
  model: selection.model,
183
221
  modelClass: selection.modelClass,
@@ -185,8 +223,11 @@ export class ModelGateway {
185
223
  stream: sanitizedBody.stream === true,
186
224
  });
187
225
  const features = requestedFeatures(sanitizedBody);
188
- if (selection.smartRouter && protocol === "openai-embeddings") {
189
- throw new ModelGatewayError(400, "Model smart-router is available only for chat requests");
226
+ if ((selection.smartRouter || selection.aiDecide) && protocol === "openai-embeddings") {
227
+ throw new ModelGatewayError(
228
+ 400,
229
+ "Virtual model routers are available only for chat requests",
230
+ );
190
231
  }
191
232
  const smartRouter =
192
233
  selection.smartRouter && this.smartRouter
@@ -203,8 +244,29 @@ export class ModelGateway {
203
244
  if (selection.smartRouter && !smartRouter) {
204
245
  throw new ModelGatewayError(404, "Databricks Smart Router is disabled");
205
246
  }
247
+ const registryTargets = smartRouter ? undefined : await this.registry.list();
248
+ const aiDecide = selection.aiDecide
249
+ ? await this.aiDecide.select(
250
+ sanitizedBody,
251
+ (registryTargets ?? [])
252
+ .map((candidate) => candidate.endpoint)
253
+ .filter((endpoint): endpoint is ServingEndpointSummary => endpoint !== undefined),
254
+ undefined,
255
+ {
256
+ strategy: aiDecideStrategy(headers),
257
+ filters: {
258
+ ...(features.tools ? { requiresTools: true } : {}),
259
+ ...(features.structuredOutput ? { requiresStructuredOutput: true } : {}),
260
+ },
261
+ signal,
262
+ },
263
+ )
264
+ : undefined;
206
265
  const target =
207
266
  smartRouter?.target ??
267
+ (aiDecide
268
+ ? registryTargets?.find((candidate) => candidate.id === aiDecide.endpoint.name)
269
+ : undefined) ??
208
270
  (await this.registry.resolve(selection.model, {
209
271
  ...(protocol === "openai-embeddings" ? { modelClass: ModelClass.Embedding } : {}),
210
272
  ...(selection.modelClass ? { modelClass: selection.modelClass } : {}),
@@ -220,7 +282,7 @@ export class ModelGateway {
220
282
  }
221
283
  const route = resolveRoute({
222
284
  clientProtocol: protocol,
223
- ...(!smartRouter && selection.model ? { requestedModel: selection.model } : {}),
285
+ ...(!smartRouter && !aiDecide && selection.model ? { requestedModel: selection.model } : {}),
224
286
  originator: headers.get("originator") ?? undefined,
225
287
  preferSystemModels: this.preferSystemModels,
226
288
  features,
@@ -255,11 +317,13 @@ export class ModelGateway {
255
317
  if (smartRouter) {
256
318
  applySmartRouterObservationHeaders(response.headers, smartRouter.observation);
257
319
  }
320
+ if (aiDecide) applyAiDecideObservationHeaders(response.headers, aiDecide.observation);
258
321
  return response;
259
322
  }
260
323
  const forwarded = gatewayResponseHeaders(response.headers);
261
324
  forwarded.set("x-dbx-tools-upstream", route.upstreamProtocol);
262
325
  if (smartRouter) applySmartRouterObservationHeaders(forwarded, smartRouter.observation);
326
+ if (aiDecide) applyAiDecideObservationHeaders(forwarded, aiDecide.observation);
263
327
  return new Response(response.body, {
264
328
  status: response.status,
265
329
  statusText: response.statusText,
@@ -330,6 +394,7 @@ export function gatewayModelSelection(
330
394
  protocol?: ClientProtocol,
331
395
  ): {
332
396
  readonly smartRouter?: boolean;
397
+ readonly aiDecide?: boolean;
333
398
  readonly model?: string;
334
399
  readonly modelClass?: ModelClassType;
335
400
  } {
@@ -342,10 +407,16 @@ export function gatewayModelSelection(
342
407
  if (model && parsedClass?.data) {
343
408
  throw new ModelGatewayError(400, "Model and model class are mutually exclusive");
344
409
  }
345
- if (model) return isSmartRouterModelId(model) ? { smartRouter: true } : { model };
410
+ if (model) {
411
+ if (isSmartRouterModelId(model)) return { smartRouter: true };
412
+ if (isAiDecideModelId(model)) return { aiDecide: true };
413
+ return { model };
414
+ }
346
415
  if (parsedClass?.data) return { modelClass: parsedClass.data };
347
416
  if (defaults.model) {
348
- return isSmartRouterModelId(defaults.model) ? { smartRouter: true } : { model: defaults.model };
417
+ if (isSmartRouterModelId(defaults.model)) return { smartRouter: true };
418
+ if (isAiDecideModelId(defaults.model)) return { aiDecide: true };
419
+ return { model: defaults.model };
349
420
  }
350
421
  if (defaults.modelClass) return { modelClass: defaults.modelClass };
351
422
  if (protocol === "openai-embeddings") return { modelClass: ModelClass.Embedding };
@@ -368,6 +439,19 @@ function applySmartRouterObservationHeaders(
368
439
  if (profilerStatus) headers.set(SMART_ROUTER_PROFILER_STATUS_HEADER, profilerStatus);
369
440
  }
370
441
 
442
+ function applyAiDecideObservationHeaders(headers: Headers, observation: AiDecideObservation): void {
443
+ headers.set(AI_DECIDE_SELECTED_MODEL_HEADER, observation.selectedModel);
444
+ headers.set(AI_DECIDE_STRATEGY_HEADER, observation.strategy);
445
+ if (observation.confidence !== undefined) {
446
+ headers.set(AI_DECIDE_CONFIDENCE_HEADER, String(observation.confidence));
447
+ }
448
+ }
449
+
450
+ function aiDecideStrategy(headers: Headers) {
451
+ const value = headers.get(AI_DECIDE_STRATEGY_HEADER)?.trim().toLowerCase();
452
+ return value ? AiDecideStrategySchema.parse(value) : "balanced";
453
+ }
454
+
371
455
  function stringParam(
372
456
  params: Readonly<Record<string, unknown>> | undefined,
373
457
  key: string,
@@ -376,17 +460,15 @@ function stringParam(
376
460
  return typeof value === "string" && value ? value : undefined;
377
461
  }
378
462
 
379
- function onlySmartRouter(payload: ModelListResponse): ModelListResponse {
463
+ function onlyVirtualModel(payload: ModelListResponse, modelId: string): ModelListResponse {
380
464
  if ("models" in payload) {
381
465
  return {
382
- models: payload.models.filter((model) =>
383
- model.slug.toLowerCase().endsWith(`/${SMART_ROUTER_MODEL_ID}`),
384
- ),
466
+ models: payload.models.filter((model) => model.slug.toLowerCase().endsWith(`/${modelId}`)),
385
467
  };
386
468
  }
387
469
  return {
388
470
  object: "list",
389
- data: payload.data.filter((model) => model.id === SMART_ROUTER_MODEL_ID),
471
+ data: payload.data.filter((model) => model.id === modelId),
390
472
  };
391
473
  }
392
474
 
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Forgiving model-context normalization for routing and suggestion generation.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { isRecord } from "@dbx-tools/shared-core";
8
+ import {
9
+ convertToModelMessages,
10
+ modelMessageSchema,
11
+ pruneMessages,
12
+ safeValidateUIMessages,
13
+ type ModelMessage,
14
+ } from "ai";
15
+ import { Tiktoken } from "js-tiktoken/lite";
16
+ import o200kBase from "js-tiktoken/ranks/o200k_base";
17
+
18
+ const CONTEXT_ENCODING = new Tiktoken(o200kBase);
19
+ const DEFAULT_TOKEN_LIMIT = 4_096;
20
+
21
+ /** One useful conversational event retained from arbitrary caller input. */
22
+ export interface ModelContextEvent {
23
+ readonly role: "assistant" | "system" | "tool" | "user";
24
+ readonly text: string;
25
+ }
26
+
27
+ /** Controls for converting arbitrary values into bounded model context. */
28
+ export interface NormalizeModelContextOptions {
29
+ /** Maximum retained tokens. Defaults to 4096. */
30
+ readonly tokenLimit?: number;
31
+ /** Remove reasoning, tool calls/results, metadata, and other non-conversational noise. */
32
+ readonly removeNonUsefulMessages?: boolean;
33
+ }
34
+
35
+ /** Bounded context plus logical-turn metadata used by model routers. */
36
+ export interface NormalizedModelContext {
37
+ readonly continuation: boolean;
38
+ readonly events: readonly ModelContextEvent[];
39
+ readonly logicalTurn: string;
40
+ readonly task: string;
41
+ readonly tokenCount: number;
42
+ }
43
+
44
+ /**
45
+ * Normalize strings, JSON, AI SDK messages, Responses input, and task-shaped objects.
46
+ *
47
+ * AI SDK UI/model message parsing is preferred. The fallback handles external wire
48
+ * shapes and arbitrary JSON without retaining credentials, metadata, or tool arguments.
49
+ */
50
+ export async function normalizeModelContext(
51
+ input: unknown,
52
+ options: NormalizeModelContextOptions = {},
53
+ ): Promise<NormalizedModelContext> {
54
+ const tokenLimit = Math.max(0, options.tokenLimit ?? DEFAULT_TOKEN_LIMIT);
55
+ const removeNonUsefulMessages = options.removeNonUsefulMessages ?? true;
56
+ const normalized = parseJsonString(input);
57
+ const conversation = conversationValue(normalized);
58
+ const sdkMessages = await aiSdkModelMessages(conversation);
59
+ const events = sdkMessages
60
+ ? modelMessageEvents(sdkMessages, removeNonUsefulMessages)
61
+ : conversationEvents(conversation, removeNonUsefulMessages);
62
+ const rawEvents = conversationEvents(conversation, false);
63
+ const stateEvents = rawEvents.length > 0 ? rawEvents : events;
64
+ if (events.length > 0) return contextFromEvents(events, tokenLimit, stateEvents);
65
+
66
+ const values = collectTaskStrings(normalized);
67
+ const task = truncateText(values.join("\n"), tokenLimit, "start");
68
+ return {
69
+ task,
70
+ logicalTurn: task,
71
+ continuation: false,
72
+ events: task ? [{ role: "user", text: task }] : [],
73
+ tokenCount: countTokens(task),
74
+ };
75
+ }
76
+
77
+ async function aiSdkModelMessages(input: unknown): Promise<ModelMessage[] | undefined> {
78
+ if (!Array.isArray(input) || input.length === 0) return undefined;
79
+ const modelMessages = input.map((value) => modelMessageSchema.safeParse(value));
80
+ if (modelMessages.every((result) => result.success)) {
81
+ return modelMessages.map((result) => result.data);
82
+ }
83
+
84
+ const uiMessages = await safeValidateUIMessages({ messages: input });
85
+ if (!uiMessages.success) return undefined;
86
+ try {
87
+ return await convertToModelMessages(uiMessages.data, { ignoreIncompleteToolCalls: true });
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function modelMessageEvents(
94
+ messages: ModelMessage[],
95
+ removeNonUsefulMessages: boolean,
96
+ ): ModelContextEvent[] {
97
+ const useful = removeNonUsefulMessages
98
+ ? pruneMessages({ messages, reasoning: "all", toolCalls: "all" })
99
+ : messages;
100
+ return useful.flatMap((message) => {
101
+ const role = message.role;
102
+ if (role === "tool" && removeNonUsefulMessages) return [];
103
+ const text = modelContentText(message.content, removeNonUsefulMessages);
104
+ return text ? [{ role, text }] : [];
105
+ });
106
+ }
107
+
108
+ function modelContentText(value: unknown, removeNonUsefulMessages: boolean): string {
109
+ if (typeof value === "string") return value.trim();
110
+ if (!Array.isArray(value)) return "";
111
+ return value
112
+ .flatMap((part) => {
113
+ if (!isRecord(part)) return [];
114
+ if (typeof part.text === "string") return [part.text.trim()];
115
+ if (!removeNonUsefulMessages && typeof part.output === "string") return [part.output.trim()];
116
+ return [];
117
+ })
118
+ .filter(Boolean)
119
+ .join("\n");
120
+ }
121
+
122
+ function conversationValue(input: unknown): unknown {
123
+ if (!isRecord(input)) return input;
124
+ for (const key of ["messages", "input"] as const) {
125
+ const value = input[key];
126
+ if (Array.isArray(value)) return value;
127
+ if (typeof value === "string" && value.trim()) return value;
128
+ }
129
+ return input;
130
+ }
131
+
132
+ function contextFromEvents(
133
+ events: readonly ModelContextEvent[],
134
+ tokenLimit: number,
135
+ stateEvents: readonly ModelContextEvent[] = events,
136
+ ): NormalizedModelContext {
137
+ const latestUserIndex = lastEventIndex(events, "user");
138
+ const taskEvent = latestUserIndex >= 0 ? events[latestUserIndex] : events.at(-1);
139
+ const task = truncateText(taskEvent?.text ?? "", tokenLimit, "start");
140
+ let remaining = Math.max(0, tokenLimit - countTokens(task));
141
+ const context: string[] = [];
142
+ for (
143
+ let index = (taskEvent ? events.indexOf(taskEvent) : events.length) - 1;
144
+ index >= 0;
145
+ index -= 1
146
+ ) {
147
+ const event = events[index];
148
+ if (!event || !event.text || event.role === "tool") continue;
149
+ const value = `${event.role}: ${event.text}`;
150
+ const retained = truncateText(value, remaining, "end");
151
+ if (!retained) break;
152
+ context.unshift(retained);
153
+ remaining -= countTokens(retained);
154
+ }
155
+ const combined = [...context, task].filter(Boolean).join("\n");
156
+ const stateLatestUserIndex = lastEventIndex(stateEvents, "user");
157
+ const logicalEvents =
158
+ stateLatestUserIndex >= 0 ? stateEvents.slice(0, stateLatestUserIndex + 1) : stateEvents;
159
+ return {
160
+ task: combined,
161
+ logicalTurn: logicalEvents.map((event) => `${event.role}:${event.text}`).join("\n"),
162
+ continuation:
163
+ stateLatestUserIndex >= 0 &&
164
+ stateEvents.slice(stateLatestUserIndex + 1).some((event) => event.role !== "system"),
165
+ events,
166
+ tokenCount: countTokens(combined),
167
+ };
168
+ }
169
+
170
+ function conversationEvents(input: unknown, removeNonUsefulMessages: boolean): ModelContextEvent[] {
171
+ if (typeof input === "string") return input.trim() ? [{ role: "user", text: input.trim() }] : [];
172
+ if (Array.isArray(input)) {
173
+ return input.flatMap((value) => messageEvent(value, removeNonUsefulMessages));
174
+ }
175
+ if (!isRecord(input)) return [];
176
+ for (const key of ["messages", "input"] as const) {
177
+ const value = input[key];
178
+ if (typeof value === "string" && value.trim()) return [{ role: "user", text: value.trim() }];
179
+ if (Array.isArray(value)) {
180
+ const events = value.flatMap((item) => messageEvent(item, removeNonUsefulMessages));
181
+ if (events.length > 0) return events;
182
+ }
183
+ }
184
+ return [];
185
+ }
186
+
187
+ function messageEvent(value: unknown, removeNonUsefulMessages: boolean): ModelContextEvent[] {
188
+ if (typeof value === "string") return value.trim() ? [{ role: "user", text: value.trim() }] : [];
189
+ if (!isRecord(value)) return [];
190
+ const role = messageRole(value);
191
+ if (!role || (removeNonUsefulMessages && role === "tool")) return [];
192
+ if (removeNonUsefulMessages && (value.type === "function_call" || value.type === "reasoning")) {
193
+ return [];
194
+ }
195
+ const text = contentText(value.content ?? value.text ?? value.input_text ?? value.output_text);
196
+ return text ? [{ role, text }] : [];
197
+ }
198
+
199
+ function messageRole(
200
+ value: Readonly<Record<string, unknown>>,
201
+ ): ModelContextEvent["role"] | undefined {
202
+ if (value.role === "user" || value.role === "assistant" || value.role === "system") {
203
+ return value.role;
204
+ }
205
+ if (
206
+ value.role === "tool" ||
207
+ value.type === "function_call_output" ||
208
+ value.type === "tool_result"
209
+ ) {
210
+ return "tool";
211
+ }
212
+ if (value.type === "function_call" || value.type === "reasoning") return "assistant";
213
+ if (value.type === "message" && value.role === undefined) return "user";
214
+ return undefined;
215
+ }
216
+
217
+ function contentText(value: unknown): string {
218
+ if (typeof value === "string") return value.trim();
219
+ if (Array.isArray(value)) return value.map(contentText).filter(Boolean).join("\n");
220
+ if (!isRecord(value)) return "";
221
+ for (const key of ["text", "input_text", "output_text", "content"] as const) {
222
+ const text = contentText(value[key]);
223
+ if (text) return text;
224
+ }
225
+ return "";
226
+ }
227
+
228
+ const TASK_KEYS = [
229
+ "prompt",
230
+ "task",
231
+ "query",
232
+ "question",
233
+ "request",
234
+ "description",
235
+ "message",
236
+ "text",
237
+ "input",
238
+ ] as const;
239
+ const IGNORED_KEYS = new Set([
240
+ "api_key",
241
+ "authorization",
242
+ "arguments",
243
+ "credentials",
244
+ "headers",
245
+ "metadata",
246
+ "password",
247
+ "provider_options",
248
+ "raw_input",
249
+ "secret",
250
+ "token",
251
+ "tool_calls",
252
+ "tools",
253
+ ]);
254
+
255
+ function collectTaskStrings(input: unknown): string[] {
256
+ if (typeof input === "string") return input.trim() ? [input.trim()] : [];
257
+ const preferred: string[] = [];
258
+ if (isRecord(input)) {
259
+ for (const key of TASK_KEYS) preferred.push(...stringLeaves(input[key], key, 0));
260
+ }
261
+ return preferred.length > 0 ? preferred.slice(0, 32) : stringLeaves(input, "", 0).slice(0, 32);
262
+ }
263
+
264
+ function stringLeaves(value: unknown, key: string, depth: number): string[] {
265
+ if (depth > 6 || IGNORED_KEYS.has(key.toLowerCase())) return [];
266
+ if (typeof value === "string") return value.trim() ? [value.trim()] : [];
267
+ if (Array.isArray(value)) return value.flatMap((item) => stringLeaves(item, key, depth + 1));
268
+ if (!isRecord(value)) return [];
269
+ return Object.entries(value).flatMap(([childKey, child]) =>
270
+ stringLeaves(child, childKey, depth + 1),
271
+ );
272
+ }
273
+
274
+ function parseJsonString(input: unknown): unknown {
275
+ if (typeof input !== "string") return input;
276
+ const trimmed = input.trim();
277
+ if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return input;
278
+ try {
279
+ return JSON.parse(trimmed) as unknown;
280
+ } catch {
281
+ return input;
282
+ }
283
+ }
284
+
285
+ function countTokens(text: string): number {
286
+ return CONTEXT_ENCODING.encode(text, [], []).length;
287
+ }
288
+
289
+ function truncateText(text: string, tokenLimit: number, edge: "start" | "end"): string {
290
+ if (tokenLimit <= 0 || !text) return "";
291
+ const tokens = CONTEXT_ENCODING.encode(text, [], []);
292
+ if (tokens.length <= tokenLimit) return text;
293
+ return CONTEXT_ENCODING.decode(
294
+ edge === "start" ? tokens.slice(0, tokenLimit) : tokens.slice(-tokenLimit),
295
+ );
296
+ }
297
+
298
+ function lastEventIndex(
299
+ events: readonly ModelContextEvent[],
300
+ role: ModelContextEvent["role"],
301
+ ): number {
302
+ for (let index = events.length - 1; index >= 0; index -= 1) {
303
+ if (events[index]?.role === role) return index;
304
+ }
305
+ return -1;
306
+ }