@dbx-tools/appkit-model-gateway 0.9.190 → 0.9.193
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 +6 -0
- package/index.ts +7 -1
- package/lib/index.d.ts +7 -1
- package/lib/index.js +6 -2
- package/lib/src/ai-decide.d.ts +50 -0
- package/lib/src/ai-decide.js +406 -0
- package/lib/src/gateway.d.ts +6 -2
- package/lib/src/gateway.js +72 -16
- package/lib/src/model-context.d.ts +32 -0
- package/lib/src/model-context.js +270 -0
- package/lib/src/models.d.ts +3 -3
- package/lib/src/models.js +55 -8
- package/lib/src/reasoning-adapt.js +2 -5
- package/lib/src/routes.js +18 -2
- package/lib/src/smart-router.d.ts +4 -7
- package/lib/src/smart-router.js +7 -175
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/ai-decide.ts +552 -0
- package/src/gateway.ts +96 -14
- package/src/model-context.ts +306 -0
- package/src/models.ts +66 -4
- package/src/reasoning-adapt.ts +1 -5
- package/src/routes.ts +16 -0
- package/src/smart-router.ts +8 -187
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
+
}
|