@dbx-tools/appkit-model-gateway 0.9.189 → 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/src/models.ts CHANGED
@@ -12,10 +12,11 @@ import {
12
12
  ModelFamily,
13
13
  modelFamily,
14
14
  modelFamilyRank,
15
+ modelMetadataFor,
15
16
  versionTuple,
16
17
  } from "@dbx-tools/model";
17
18
  import { ModelClass, ReasoningEffort } from "@dbx-tools/shared-model";
18
- import { SMART_ROUTER_MODEL_ID } from "@dbx-tools/shared-model-gateway";
19
+ import { AI_DECIDE_MODEL_ID, SMART_ROUTER_MODEL_ID } from "@dbx-tools/shared-model-gateway";
19
20
  import type {
20
21
  CodexModel,
21
22
  CodexModelListResponse,
@@ -29,6 +30,7 @@ import type {
29
30
  const CODEX_BASE_INSTRUCTIONS =
30
31
  "You are a coding agent. Follow the user's instructions and use the available tools to work in the current repository.";
31
32
  const CODEX_SMART_ROUTER_PRIORITY = 9_000;
33
+ const CODEX_AI_DECIDE_PRIORITY = 9_001;
32
34
  const CODEX_MODEL_PRIORITY_BASE = 10_000;
33
35
  const CODEX_FAMILY_ORDER = [
34
36
  ModelFamily.Gpt,
@@ -52,6 +54,8 @@ export function listModelsPayload(
52
54
  preserveOrder?: boolean,
53
55
  preferSystemModels?: boolean,
54
56
  includeSmartRouter?: boolean,
57
+ includeAiDecide?: boolean,
58
+ includeMetadata?: boolean,
55
59
  ): CodexModelListResponse;
56
60
  export function listModelsPayload(
57
61
  targets: readonly ModelTarget[],
@@ -59,6 +63,8 @@ export function listModelsPayload(
59
63
  preserveOrder?: boolean,
60
64
  preferSystemModels?: boolean,
61
65
  includeSmartRouter?: boolean,
66
+ includeAiDecide?: boolean,
67
+ includeMetadata?: boolean,
62
68
  ): OpenAIModelListResponse;
63
69
  export function listModelsPayload(
64
70
  targets: readonly ModelTarget[],
@@ -66,6 +72,8 @@ export function listModelsPayload(
66
72
  preserveOrder?: boolean,
67
73
  preferSystemModels?: boolean,
68
74
  includeSmartRouter?: boolean,
75
+ includeAiDecide?: boolean,
76
+ includeMetadata?: boolean,
69
77
  ): ModelListResponse;
70
78
  export function listModelsPayload(
71
79
  targets: readonly ModelTarget[],
@@ -73,6 +81,8 @@ export function listModelsPayload(
73
81
  preserveOrder = false,
74
82
  preferSystemModels = false,
75
83
  includeSmartRouter = false,
84
+ includeAiDecide = false,
85
+ includeMetadata = false,
76
86
  ): ModelListResponse {
77
87
  const available = targets
78
88
  .filter((target) => target.endpoint?.status?.deprecated !== true)
@@ -85,6 +95,9 @@ export function listModelsPayload(
85
95
  );
86
96
  }
87
97
  const smartRouterCandidates = available.filter((target) => target.capabilities.chat);
98
+ if (includeAiDecide && smartRouterCandidates.length > 0) {
99
+ available.unshift(aiDecideModelTarget(smartRouterCandidates));
100
+ }
88
101
  if (includeSmartRouter && smartRouterCandidates.length > 0) {
89
102
  available.unshift(smartRouterModelTarget(smartRouterCandidates));
90
103
  }
@@ -96,29 +109,35 @@ export function listModelsPayload(
96
109
  target,
97
110
  target.id === SMART_ROUTER_MODEL_ID
98
111
  ? CODEX_SMART_ROUTER_PRIORITY
99
- : CODEX_MODEL_PRIORITY_BASE + modelIndex++,
112
+ : target.id === AI_DECIDE_MODEL_ID
113
+ ? CODEX_AI_DECIDE_PRIORITY
114
+ : CODEX_MODEL_PRIORITY_BASE + modelIndex++,
100
115
  preferSystemModels,
116
+ includeMetadata,
101
117
  ),
102
118
  ),
103
119
  };
104
120
  }
105
121
  return {
106
122
  object: "list",
107
- data: available.map(openAiModel),
123
+ data: available.map((target) => openAiModel(target, includeMetadata)),
108
124
  };
109
125
  }
110
126
 
111
- function openAiModel(target: ModelTarget): OpenAIModel {
127
+ function openAiModel(target: ModelTarget, includeMetadata: boolean): OpenAIModel {
112
128
  return {
113
129
  id: target.id,
114
130
  object: "model",
115
131
  created: 0,
116
132
  owned_by: "databricks",
117
133
  name: target.displayName,
134
+ model_source: target.endpoint?.modelSource ?? "custom",
118
135
  task: target.endpoint?.task,
119
136
  status: target.endpoint?.status ?? { deprecated: false },
137
+ ...(includeMetadata ? { metadata: modelMetadataFor(target.endpoint ?? target.id) } : {}),
120
138
  capabilities: {
121
139
  smart_router: target.id === SMART_ROUTER_MODEL_ID,
140
+ ai_decide: target.id === AI_DECIDE_MODEL_ID,
122
141
  tools: target.capabilities.tools,
123
142
  reasoning: target.reasoningEfforts,
124
143
  responses: target.capabilities.responses,
@@ -134,6 +153,46 @@ function openAiModel(target: ModelTarget): OpenAIModel {
134
153
  };
135
154
  }
136
155
 
156
+ function aiDecideModelTarget(candidates: readonly ModelTarget[]): ModelTarget {
157
+ const has = (capability: keyof ModelCapabilities) =>
158
+ candidates.some((target) => target.capabilities[capability]);
159
+ const reasoningEfforts = Object.values(ReasoningEffort).filter((effort) =>
160
+ candidates.some((target) => target.reasoningEfforts.includes(effort)),
161
+ );
162
+ return {
163
+ id: AI_DECIDE_MODEL_ID,
164
+ aliases: [AI_DECIDE_MODEL_ID],
165
+ displayName: "AI Decide",
166
+ endpoint: {
167
+ name: AI_DECIDE_MODEL_ID,
168
+ displayName: "AI Decide",
169
+ description: "Uses Databricks ai_decide to choose a sufficient foundation model.",
170
+ task: "llm/v1/chat",
171
+ class: ModelClass.ChatBalanced,
172
+ modelSource: "foundation",
173
+ status: { deprecated: false },
174
+ },
175
+ capabilities: {
176
+ responses: true,
177
+ openResponses: true,
178
+ chat: true,
179
+ anthropic: true,
180
+ embeddings: false,
181
+ aiGatewayCodex: true,
182
+ tools: has("tools"),
183
+ reasoning: reasoningEfforts.length > 0,
184
+ streaming: true,
185
+ parallelTools: has("parallelTools"),
186
+ customTools: has("customTools"),
187
+ structuredOutput: has("structuredOutput"),
188
+ webSearch: has("webSearch"),
189
+ imageInput: has("imageInput"),
190
+ applyPatch: has("applyPatch"),
191
+ },
192
+ reasoningEfforts,
193
+ };
194
+ }
195
+
137
196
  function smartRouterModelTarget(candidates: readonly ModelTarget[]): ModelTarget {
138
197
  const has = (capability: keyof ModelCapabilities) =>
139
198
  candidates.some((target) => target.capabilities[capability]);
@@ -151,6 +210,7 @@ function smartRouterModelTarget(candidates: readonly ModelTarget[]): ModelTarget
151
210
  "Uses Databricks task_v3 Smart Router to select a compatible system model for each turn.",
152
211
  task: "llm/v1/chat",
153
212
  class: ModelClass.ChatBalanced,
213
+ modelSource: "foundation",
154
214
  status: { deprecated: false },
155
215
  },
156
216
  capabilities: {
@@ -187,6 +247,7 @@ function codexModel(
187
247
  target: ModelTarget,
188
248
  priority: number,
189
249
  preferSystemModels: boolean,
250
+ includeMetadata: boolean,
190
251
  ): CodexModel {
191
252
  const efforts = target.reasoningEfforts;
192
253
  return {
@@ -195,6 +256,7 @@ function codexModel(
195
256
  description: target.endpoint?.description ?? "Databricks model service",
196
257
  base_instructions: CODEX_BASE_INSTRUCTIONS,
197
258
  status: target.endpoint?.status ?? { deprecated: false },
259
+ ...(includeMetadata ? { metadata: modelMetadataFor(target.endpoint ?? target.id) } : {}),
198
260
  supported_reasoning_levels: efforts.map((effort) => ({
199
261
  effort,
200
262
  description: REASONING_DESCRIPTIONS[effort] ?? `Use the ${effort} reasoning budget`,
@@ -14,7 +14,7 @@ import {
14
14
  adaptRequestReasoning,
15
15
  parseReasoningLevels,
16
16
  } from "@dbx-tools/model";
17
- import { log } from "@dbx-tools/shared-core";
17
+ import { isRecord, log } from "@dbx-tools/shared-core";
18
18
 
19
19
  const logger = log.logger("appkit/model-gateway/reasoning");
20
20
  const INVALID_ENCRYPTED_REASONING_PATTERN =
@@ -123,7 +123,3 @@ function stripRequestReasoning(
123
123
  delete stripped.reasoning_effort;
124
124
  return stripped;
125
125
  }
126
-
127
- function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
128
- return typeof value === "object" && value !== null && !Array.isArray(value);
129
- }
package/src/routes.ts CHANGED
@@ -10,6 +10,7 @@ import { log, toError } from "@dbx-tools/shared-core";
10
10
  import type { ClientProtocol } from "@dbx-tools/shared-model-gateway";
11
11
  import type express from "express";
12
12
 
13
+ import { AiDecideRouterError } from "./ai-decide.ts";
13
14
  import { ModelGateway, ModelGatewayError } from "./gateway.ts";
14
15
  import { UnsupportedGatewayFeatureError } from "./router.ts";
15
16
  import { SmartRouterError } from "./smart-router.ts";
@@ -33,6 +34,8 @@ export async function sendModels(
33
34
  const payload = await gateway.models(
34
35
  request.header("originator"),
35
36
  optionalSearch(request.query.search),
37
+ optionalModelSource(request.query.model_source ?? request.query.modelSource),
38
+ optionalBoolean(request.query.metadata, "metadata"),
36
39
  );
37
40
  response.json(payload);
38
41
  logger.debug("models response", {
@@ -130,6 +133,7 @@ export async function sendInference(
130
133
  function sendError(protocol: ClientProtocol, response: express.Response, error: unknown): void {
131
134
  const status =
132
135
  error instanceof ModelGatewayError ||
136
+ error instanceof AiDecideRouterError ||
133
137
  error instanceof SmartRouterError ||
134
138
  error instanceof UnsupportedGatewayFeatureError
135
139
  ? error.status
@@ -175,6 +179,18 @@ function optionalSearch(value: unknown): string | undefined {
175
179
  return value;
176
180
  }
177
181
 
182
+ function optionalModelSource(value: unknown): "foundation" | "custom" | "all" {
183
+ if (value === undefined) return "foundation";
184
+ if (value === "foundation" || value === "custom" || value === "all") return value;
185
+ throw new ModelGatewayError(400, "model_source must be foundation, custom, or all");
186
+ }
187
+
188
+ function optionalBoolean(value: unknown, name: string): boolean {
189
+ if (value === undefined || value === "false") return false;
190
+ if (value === "true") return true;
191
+ throw new ModelGatewayError(400, `${name} must be true or false`);
192
+ }
193
+
178
194
  function requestContext(request: express.Request): Record<string, unknown> {
179
195
  return {
180
196
  method: request.method,
@@ -22,10 +22,9 @@ import {
22
22
  type SmartRouterObservation,
23
23
  type SmartRouterRequest,
24
24
  } from "@dbx-tools/shared-model-gateway";
25
- import { Tiktoken } from "js-tiktoken/lite";
26
- import o200kBase from "js-tiktoken/ranks/o200k_base";
27
25
  import { z } from "zod";
28
26
 
27
+ import { normalizeModelContext, type NormalizedModelContext } from "./model-context.ts";
29
28
  import { resolveRoute } from "./router.ts";
30
29
 
31
30
  const logger = log.logger("appkit/model-gateway/smart-router");
@@ -35,7 +34,6 @@ const PROBE_CACHE_NAMESPACE = "model-gateway-smart-router-probe";
35
34
  const PROBE_CACHE_VERSION = 1;
36
35
  const PROBE_TASK = "Check whether Smart Router can select an available model.";
37
36
  const NEGATIVE_PROBE_TTL_MS = 30_000;
38
- const CONTEXT_ENCODING = new Tiktoken(o200kBase);
39
37
 
40
38
  /** Runtime settings for Smart Router. */
41
39
  export interface SmartRouterSettings {
@@ -109,11 +107,7 @@ export interface SmartRouterResult {
109
107
  }
110
108
 
111
109
  /** Bounded task context extracted from an arbitrary supported input shape. */
112
- export interface SmartRouterContext {
113
- readonly continuation: boolean;
114
- readonly logicalTurn: string;
115
- readonly task: string;
116
- }
110
+ export type SmartRouterContext = NormalizedModelContext;
117
111
 
118
112
  /** Error carrying an HTTP status for Smart Router validation or availability failures. */
119
113
  export class SmartRouterError extends Error {
@@ -210,7 +204,7 @@ export class SmartRouterService {
210
204
  candidates: readonly SmartRouterCandidate<T>[],
211
205
  options: SmartRouterSelectOptions = {},
212
206
  ): Promise<SmartRouterSelection<T>> {
213
- const task = this.context.build(input);
207
+ const task = await this.context.build(input);
214
208
  if (!task) throw new SmartRouterError(400, "Smart Router input contains no routable text");
215
209
  const client = options.client ?? getExecutionContext().client;
216
210
  try {
@@ -450,18 +444,13 @@ export class SmartRouterContextBuilder {
450
444
  constructor(private readonly tokenBudget = 4_096) {}
451
445
 
452
446
  /** Return only the bounded task prompt required by routes:select. */
453
- build(input: unknown): string {
454
- return this.analyze(input).task;
447
+ async build(input: unknown): Promise<string> {
448
+ return (await this.analyze(input)).task;
455
449
  }
456
450
 
457
451
  /** Return bounded task text plus logical-turn metadata used for pinning. */
458
- analyze(input: unknown): SmartRouterContext {
459
- const normalized = parseJsonString(input);
460
- const events = conversationEvents(normalized);
461
- if (events.length > 0) return contextFromEvents(events, this.tokenBudget);
462
- const values = collectTaskStrings(normalized);
463
- const task = truncateText(values.join("\n"), this.tokenBudget, "start");
464
- return { task, logicalTurn: task, continuation: false };
452
+ analyze(input: unknown): Promise<SmartRouterContext> {
453
+ return normalizeModelContext(input, { tokenLimit: this.tokenBudget });
465
454
  }
466
455
  }
467
456
 
@@ -520,7 +509,7 @@ export class SmartModelRouter {
520
509
  );
521
510
  }
522
511
 
523
- const context = this.service.context.analyze(input.body);
512
+ const context = await this.service.context.analyze(input.body);
524
513
  if (!context.task) {
525
514
  throw new SmartRouterError(400, "Smart Router request contains no task text");
526
515
  }
@@ -670,174 +659,6 @@ function requestedReasoningEffort(
670
659
  return parsed.data;
671
660
  }
672
661
 
673
- interface ConversationEvent {
674
- readonly role: "assistant" | "system" | "tool" | "user";
675
- readonly text: string;
676
- }
677
-
678
- function contextFromEvents(
679
- events: readonly ConversationEvent[],
680
- tokenBudget: number,
681
- ): SmartRouterContext {
682
- const latestUserIndex = lastEventIndex(events, "user");
683
- const taskEvent = latestUserIndex >= 0 ? events[latestUserIndex] : events.at(-1);
684
- const task = truncateText(taskEvent?.text ?? "", tokenBudget, "start");
685
- let remaining = Math.max(0, tokenBudget - countTokens(task));
686
- const context: string[] = [];
687
- for (
688
- let index = (taskEvent ? events.indexOf(taskEvent) : events.length) - 1;
689
- index >= 0;
690
- index -= 1
691
- ) {
692
- const event = events[index];
693
- if (!event || !event.text || event.role === "tool") continue;
694
- const value = `${event.role}: ${event.text}`;
695
- const retained = truncateText(value, remaining, "end");
696
- if (!retained) break;
697
- context.unshift(retained);
698
- remaining -= countTokens(retained);
699
- }
700
- const combined = [...context, task].filter(Boolean).join("\n");
701
- const logicalEvents = latestUserIndex >= 0 ? events.slice(0, latestUserIndex + 1) : events;
702
- return {
703
- task: combined,
704
- logicalTurn: logicalEvents.map((event) => `${event.role}:${event.text}`).join("\n"),
705
- continuation:
706
- latestUserIndex >= 0 &&
707
- events.slice(latestUserIndex + 1).some((event) => event.role !== "system"),
708
- };
709
- }
710
-
711
- function conversationEvents(input: unknown): ConversationEvent[] {
712
- if (typeof input === "string") return input.trim() ? [{ role: "user", text: input.trim() }] : [];
713
- if (Array.isArray(input)) return input.flatMap(messageEvent);
714
- if (!isRecord(input)) return [];
715
- for (const key of ["messages", "input"] as const) {
716
- const value = input[key];
717
- if (typeof value === "string" && value.trim()) return [{ role: "user", text: value.trim() }];
718
- if (Array.isArray(value)) {
719
- const events = value.flatMap(messageEvent);
720
- if (events.length > 0) return events;
721
- }
722
- }
723
- return [];
724
- }
725
-
726
- function messageEvent(value: unknown): ConversationEvent[] {
727
- if (typeof value === "string") return value.trim() ? [{ role: "user", text: value.trim() }] : [];
728
- if (!isRecord(value)) return [];
729
- const role = messageRole(value);
730
- if (!role) return [];
731
- const text = contentText(value.content ?? value.text ?? value.input_text ?? value.output_text);
732
- return text ? [{ role, text }] : [];
733
- }
734
-
735
- function messageRole(
736
- value: Readonly<Record<string, unknown>>,
737
- ): ConversationEvent["role"] | undefined {
738
- if (value.role === "user" || value.role === "assistant" || value.role === "system") {
739
- return value.role;
740
- }
741
- if (
742
- value.role === "tool" ||
743
- value.type === "function_call_output" ||
744
- value.type === "tool_result"
745
- ) {
746
- return "tool";
747
- }
748
- if (value.type === "function_call" || value.type === "reasoning") return "assistant";
749
- if (value.type === "message" && value.role === undefined) return "user";
750
- return undefined;
751
- }
752
-
753
- function contentText(value: unknown): string {
754
- if (typeof value === "string") return value.trim();
755
- if (Array.isArray(value)) return value.map(contentText).filter(Boolean).join("\n");
756
- if (!isRecord(value)) return "";
757
- for (const key of ["text", "input_text", "output_text", "content"] as const) {
758
- const text = contentText(value[key]);
759
- if (text) return text;
760
- }
761
- return "";
762
- }
763
-
764
- const TASK_KEYS = [
765
- "prompt",
766
- "task",
767
- "query",
768
- "question",
769
- "request",
770
- "description",
771
- "message",
772
- "text",
773
- "input",
774
- ] as const;
775
- const IGNORED_KEYS = new Set([
776
- "api_key",
777
- "authorization",
778
- "arguments",
779
- "credentials",
780
- "headers",
781
- "metadata",
782
- "password",
783
- "secret",
784
- "token",
785
- "tools",
786
- ]);
787
-
788
- function collectTaskStrings(input: unknown): string[] {
789
- if (typeof input === "string") return input.trim() ? [input.trim()] : [];
790
- const preferred: string[] = [];
791
- if (isRecord(input)) {
792
- for (const key of TASK_KEYS) preferred.push(...stringLeaves(input[key], key, 0));
793
- }
794
- return preferred.length > 0 ? preferred.slice(0, 32) : stringLeaves(input, "", 0).slice(0, 32);
795
- }
796
-
797
- function stringLeaves(value: unknown, key: string, depth: number): string[] {
798
- if (depth > 6 || IGNORED_KEYS.has(key.toLowerCase())) return [];
799
- if (typeof value === "string") return value.trim() ? [value.trim()] : [];
800
- if (Array.isArray(value)) return value.flatMap((item) => stringLeaves(item, key, depth + 1));
801
- if (!isRecord(value)) return [];
802
- return Object.entries(value).flatMap(([childKey, child]) =>
803
- stringLeaves(child, childKey, depth + 1),
804
- );
805
- }
806
-
807
- function parseJsonString(input: unknown): unknown {
808
- if (typeof input !== "string") return input;
809
- const trimmed = input.trim();
810
- if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return input;
811
- try {
812
- return JSON.parse(trimmed) as unknown;
813
- } catch {
814
- return input;
815
- }
816
- }
817
-
818
- function countTokens(text: string): number {
819
- return CONTEXT_ENCODING.encode(text, [], []).length;
820
- }
821
-
822
- function truncateText(text: string, tokenBudget: number, edge: "start" | "end"): string {
823
- if (tokenBudget <= 0 || !text) return "";
824
- const tokens = CONTEXT_ENCODING.encode(text, [], []);
825
- if (tokens.length <= tokenBudget) return text;
826
- return CONTEXT_ENCODING.decode(
827
- edge === "start" ? tokens.slice(0, tokenBudget) : tokens.slice(-tokenBudget),
828
- );
829
- }
830
-
831
- function lastEventIndex(
832
- events: readonly ConversationEvent[],
833
- role: ConversationEvent["role"],
834
- ): number {
835
- for (let index = events.length - 1; index >= 0; index -= 1) {
836
- if (events[index]?.role === role) return index;
837
- }
838
- return -1;
839
- }
840
-
841
662
  function uniqueCandidates<T>(
842
663
  candidates: readonly SmartRouterCandidate<T>[],
843
664
  ): SmartRouterCandidate<T>[] {