@juspay/neurolink 13.4.9 → 13.5.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/README.md +14 -4
  3. package/dist/browser/neurolink.min.js +419 -419
  4. package/dist/cli/commands/decide.js +25 -5
  5. package/dist/factories/providerDescriptors.d.ts +4 -3
  6. package/dist/factories/providerDescriptors.js +6 -4
  7. package/dist/hitl/hitlManager.d.ts +1 -0
  8. package/dist/hitl/hitlManager.js +2 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/neurolink.d.ts +4 -2
  12. package/dist/neurolink.js +36 -10
  13. package/dist/providers/catalog/provider-catalog.schema.json +64 -3
  14. package/dist/providers/catalog/schema.js +81 -1
  15. package/dist/providers/catalog/xor.json +30 -10
  16. package/dist/providers/configuredDecision.d.ts +8 -1
  17. package/dist/providers/configuredDecision.js +22 -0
  18. package/dist/providers/decide/dialects/openaiDecisions.js +8 -0
  19. package/dist/providers/decide/dialects/systemOne.js +28 -2
  20. package/dist/providers/decide/guards.d.ts +6 -0
  21. package/dist/providers/decide/guards.js +22 -0
  22. package/dist/providers/systemOneDecision.d.ts +30 -7
  23. package/dist/providers/systemOneDecision.js +71 -14
  24. package/dist/types/cli.d.ts +2 -0
  25. package/dist/types/decision.d.ts +117 -10
  26. package/dist/types/providerCatalog.d.ts +12 -1
  27. package/dist/types/providers.d.ts +2 -0
  28. package/dist/utils/decisionAnswers.d.ts +7 -1
  29. package/dist/utils/decisionAnswers.js +43 -2
  30. package/dist/utils/decisionLimits.d.ts +13 -1
  31. package/dist/utils/decisionLimits.js +52 -1
  32. package/dist/utils/decisionMedia.d.ts +4 -2
  33. package/dist/utils/decisionMedia.js +216 -14
  34. package/docs-site/static/search-index.json +24 -21
  35. package/package.json +1 -1
@@ -23,7 +23,7 @@ import { NeuroLink } from "../../neurolink.js";
23
23
  import { readDecisionChoice } from "../../utils/decisionAnswers.js";
24
24
  import { calculateCost, hasPricing } from "../../utils/pricing.js";
25
25
  import { logger } from "../../utils/logger.js";
26
- const VALID_QUESTION_TYPES = new Set(["boolean", "choice", "score"]);
26
+ const VALID_QUESTION_TYPES = new Set(["boolean", "choice", "score", "value"]);
27
27
  function isRecord(value) {
28
28
  return typeof value === "object" && value !== null && !Array.isArray(value);
29
29
  }
@@ -123,6 +123,10 @@ function formatAnswerLine(id, answers) {
123
123
  }
124
124
  case "score":
125
125
  return `${id}: score ${formatNumber(answer.score)}, confidence ${formatNumber(answer.confidence)}`;
126
+ case "value":
127
+ return answer.value === null
128
+ ? `${id}: no value found, confidence ${formatNumber(answer.confidence)}`
129
+ : `${id}: value ${JSON.stringify(answer.value)}, confidence ${formatNumber(answer.confidence)}`;
126
130
  default:
127
131
  return `${id}: (unrecognised answer type)`;
128
132
  }
@@ -195,12 +199,17 @@ export const decideCommand = {
195
199
  type: "string",
196
200
  array: true,
197
201
  nargs: 1,
198
- describe: "Image for the model to read: a file path or a data: URL. Repeat for several (XOR, Perplexity and OpenAI Decisions take up to 8, Cloudflare Clef up to 4)",
202
+ describe: "Image for the model to read: a file path or a data: URL. Repeat for several (Perplexity and OpenAI Decisions take up to 8, Cloudflare Clef up to 4, XOR 1)",
199
203
  })
200
204
  .option("video", {
201
205
  type: "string",
202
206
  nargs: 1,
203
- describe: "One video for the model to read: a file path or a data: URL",
207
+ describe: "One video for the model to read: a file path or a data: URL. No current decision provider reads video, so it is refused before any request",
208
+ })
209
+ .option("audio", {
210
+ type: "string",
211
+ nargs: 1,
212
+ describe: "One audio clip for the model to hear: a WAV, FLAC, Ogg or MP3 file path or a data: URL (XOR with its xor-omni model; the first 30 seconds are heard). State may then be omitted",
204
213
  })
205
214
  .option("timeout", {
206
215
  type: "number",
@@ -221,7 +230,8 @@ export const decideCommand = {
221
230
  })
222
231
  .example('$0 decide "Refund request for a damaged item" --questions \'{"urgent":{"type":"boolean","instructions":"Is this urgent?"}}\'', "Ask a single yes/no question")
223
232
  .example("$0 decide --state-file ticket.json --questions-file questions.json --format json", "Read state and questions from files, emit raw JSON")
224
- .example('$0 decide "What color is this?" --provider xor --model xor-omni --image ./photo.png --questions \'{"color":{"type":"choice","instructions":"What color is the image?","criteria":{"red":"red","blue":"blue"}}}\'', "Ask about an image (XOR with its xor-omni model, Perplexity, Cloudflare Clef and OpenAI Decisions read images)"),
233
+ .example('$0 decide "What color is this?" --provider xor --model xor-omni --image ./photo.png --questions \'{"color":{"type":"choice","instructions":"What color is the image?","criteria":{"red":"red","blue":"blue"}}}\'', "Ask about an image (XOR with its xor-omni model, Perplexity, Cloudflare Clef and OpenAI Decisions read images)")
234
+ .example('$0 decide "Invoice INV-2024-0892 for 18,275 rupees" --provider xor --model xor-omni --questions \'{"amount":{"type":"value","dtype":"int","instructions":"Invoice amount?"}}\'', "Extract a typed value (XOR)"),
225
235
  handler: async (argv) => {
226
236
  const outputFormat = argv.format ?? "text";
227
237
  // --- Validate before any provider work ---------------------------------
@@ -256,8 +266,13 @@ export const decideCommand = {
256
266
  logger.error(chalk.red("Error: pass at most one --video."));
257
267
  process.exit(1);
258
268
  }
269
+ const audios = [].concat(argv.audio ?? []);
270
+ if (audios.length > 1) {
271
+ logger.error(chalk.red("Error: pass at most one --audio."));
272
+ process.exit(1);
273
+ }
259
274
  if (!isDecisionQuestionMap(parsedQuestions)) {
260
- logger.error(chalk.red('Error: questions must be a non-empty object of { type: "boolean" | "choice" | "score", ... }.'));
275
+ logger.error(chalk.red('Error: questions must be a non-empty object of { type: "boolean" | "choice" | "score" | "value", ... }.'));
261
276
  process.exit(1);
262
277
  }
263
278
  let state;
@@ -268,6 +283,10 @@ export const decideCommand = {
268
283
  logger.error(chalk.red(`Error: could not read state file — ${error instanceof Error ? error.message : String(error)}`));
269
284
  process.exit(1);
270
285
  }
286
+ // An audio clip can be the whole input.
287
+ if (state === undefined && audios.length === 1) {
288
+ state = "";
289
+ }
271
290
  if (state === undefined) {
272
291
  logger.error(chalk.red("Error: provide state as a positional argument or via --state-file."));
273
292
  process.exit(1);
@@ -284,6 +303,7 @@ export const decideCommand = {
284
303
  timeoutMs: argv.timeout,
285
304
  ...(argv.image && argv.image.length > 0 ? { images: argv.image } : {}),
286
305
  ...(videos.length === 1 ? { video: videos[0] } : {}),
306
+ ...(audios.length === 1 ? { audio: audios[0] } : {}),
287
307
  });
288
308
  spinner?.succeed("Decision complete");
289
309
  if (outputFormat === "json") {
@@ -45,12 +45,13 @@ export declare const DECISION_PROVIDERS: readonly ProviderDescriptor[];
45
45
  */
46
46
  export declare function resolveDefaultDecisionProvider(credentials?: NeurolinkCredentials): string | undefined;
47
47
  /**
48
- * Names of the decision providers that accept images, or, with `video`, the
49
- * ones that accept video too: a hint that names a provider which would refuse
50
- * the same request is worse than none.
48
+ * Names of the decision providers that accept images, or, with `video` or
49
+ * `audio`, the ones that accept that too: a hint that names a provider which
50
+ * would refuse the same request is worse than none.
51
51
  */
52
52
  export declare function listMediaDecisionProviders(needs?: {
53
53
  video?: boolean;
54
+ audio?: boolean;
54
55
  }): string[];
55
56
  /**
56
57
  * The variables that would configure each decision provider, one clause per
@@ -909,14 +909,16 @@ export function resolveDefaultDecisionProvider(credentials) {
909
909
  return DECISION_PROVIDERS.find((descriptor) => isDecisionProviderConfigured(descriptor, credentials))?.name;
910
910
  }
911
911
  /**
912
- * Names of the decision providers that accept images, or, with `video`, the
913
- * ones that accept video too: a hint that names a provider which would refuse
914
- * the same request is worse than none.
912
+ * Names of the decision providers that accept images, or, with `video` or
913
+ * `audio`, the ones that accept that too: a hint that names a provider which
914
+ * would refuse the same request is worse than none.
915
915
  */
916
916
  export function listMediaDecisionProviders(needs) {
917
917
  return DECISION_PROVIDERS.filter((descriptor) => {
918
918
  const media = descriptor.decisionLimits?.media;
919
- return media !== undefined && (!needs?.video || media.video);
919
+ return (media !== undefined &&
920
+ (!needs?.video || media.video) &&
921
+ (!needs?.audio || media.audio !== undefined));
920
922
  }).map((descriptor) => descriptor.name);
921
923
  }
922
924
  /** "a", "a or b", "a, b or c". */
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { EventEmitter } from "events";
9
9
  import type { HITLConfig, ConfirmationResult, HITLStatistics } from "../types/index.js";
10
+ export declare const DEFAULT_ALLOW_MODIFICATION = false;
10
11
  /**
11
12
  * HITLManager - Central orchestrator for Human-in-the-Loop safety mechanisms
12
13
  *
@@ -11,7 +11,8 @@ import { HITLTimeoutError, HITLConfigurationError } from "./hitlErrors.js";
11
11
  import { logger } from "../utils/logger.js";
12
12
  // Default configuration constants
13
13
  const DEFAULT_TIMEOUT = 30000; // 30 seconds
14
- const DEFAULT_ALLOW_MODIFICATION = false;
14
+ // Shared with NeuroLink's HITL init logs so they report the policy applied here
15
+ export const DEFAULT_ALLOW_MODIFICATION = false;
15
16
  /**
16
17
  * HITLManager - Central orchestrator for Human-in-the-Loop safety mechanisms
17
18
  *
package/dist/index.d.ts CHANGED
@@ -416,7 +416,7 @@ export { classifyProviderError, ModelPool, createDefaultRequestRouter, Classifie
416
416
  * if (team && team.confidence > 0.7) { route(team.choice); }
417
417
  * ```
418
418
  */
419
- export { decisionBooleanConfidence, decisionKey, gateDecisionBoolean, HOST_DECISION_NAMESPACE, isDecisionQuestion, readDecisionBoolean, readDecisionChoice, readDecisionScore, servesInferenceKind, } from "./utils/decisionAnswers.js";
419
+ export { decisionBooleanConfidence, decisionKey, gateDecisionBoolean, HOST_DECISION_NAMESPACE, isDecisionQuestion, readDecisionBoolean, readDecisionChoice, readDecisionScore, readDecisionValue, servesInferenceKind, } from "./utils/decisionAnswers.js";
420
420
  export { estimateDecisionStateTokens, resolveDecisionLimitsReading, } from "./utils/decisionLimits.js";
421
421
  export { DECISION_PROVIDERS, resolveDefaultDecisionProvider, } from "./factories/providerDescriptors.js";
422
422
  export { TYPESAFE_MAX_REQUEST_TOKENS, TYPESAFE_MAX_STATE_TOKENS, } from "./providers/typesafe.js";
package/dist/index.js CHANGED
@@ -633,7 +633,7 @@ export { classifyProviderError, ModelPool, createDefaultRequestRouter, Classifie
633
633
  * if (team && team.confidence > 0.7) { route(team.choice); }
634
634
  * ```
635
635
  */
636
- export { decisionBooleanConfidence, decisionKey, gateDecisionBoolean, HOST_DECISION_NAMESPACE, isDecisionQuestion, readDecisionBoolean, readDecisionChoice, readDecisionScore, servesInferenceKind, } from "./utils/decisionAnswers.js";
636
+ export { decisionBooleanConfidence, decisionKey, gateDecisionBoolean, HOST_DECISION_NAMESPACE, isDecisionQuestion, readDecisionBoolean, readDecisionChoice, readDecisionScore, readDecisionValue, servesInferenceKind, } from "./utils/decisionAnswers.js";
637
637
  // The estimator the pre-flight refusal uses, and the flattening behind
638
638
  // `neurolink.decisionLimits()` — exported so a host measures a state with the
639
639
  // very function that will judge it.
@@ -2694,12 +2694,14 @@ export declare class NeuroLink {
2694
2694
  * many would have the WHOLE request refused, and NeuroLink's own routing
2695
2695
  * would silently degrade to the heuristic — the host's problem would
2696
2696
  * become NeuroLink's. A provider that declares no `maxQuestions`
2697
- * (TypeSafe, XOR) takes every well-formed addition.
2697
+ * (TypeSafe) takes every well-formed addition. For the same reason a
2698
+ * question the provider cannot answer (a type it does not list, a choice
2699
+ * or score past its cap) is dropped on its own.
2698
2700
  * 4. Answers are split: the consumer sees only its own ids, exactly as it
2699
2701
  * would without hooks; `onAnswers` gets the host's answers under the
2700
2702
  * host's original ids plus the full result.
2701
2703
  *
2702
- * The request's `images` and `video` ride along untouched — `merged` and
2704
+ * The request's `images`, `video` and `audio` ride along untouched — `merged` and
2703
2705
  * the wire call spread the request, and only `state`, the questions and
2704
2706
  * the result are cloned — so an XOR request keeps its Buffers and its
2705
2707
  * result keeps `mediaBytes`.
package/dist/neurolink.js CHANGED
@@ -37,14 +37,14 @@ import { describeDecisionProviderKeys, PROVIDER_ALIAS_INDEX, PROVIDER_DESCRIPTOR
37
37
  import { describeSTTProviderKeys, getSTTProviderDescriptor, listConfiguredSTTProviders, resolveCallSTTEndpoints, resolveDefaultSTTProvider, resolveSTTProviderName, } from "./factories/sttDescriptors.js";
38
38
  import { MAX_PARALLEL_DECISION_BATCHES, mergeDecisionResults, splitDecisionRequest, } from "./utils/decisionBatches.js";
39
39
  import { decisionKey, HOST_DECISION_NAMESPACE, isHostDecisionKey, snapshotDecisionQuestion, stripHostAnswers, } from "./utils/decisionAnswers.js";
40
- import { resolveDecisionLimitsReading } from "./utils/decisionLimits.js";
40
+ import { resolveDecisionLimitsReading, unsupportedDecisionQuestion, } from "./utils/decisionLimits.js";
41
41
  import { isDirectTTSRequest, resolveRequestKind, } from "./core/resolveRequestKind.js";
42
42
  import { createToolEventPayload } from "./core/toolEvents.js";
43
43
  import { ProviderFactory } from "./factories/providerFactory.js";
44
44
  import { ProviderRegistry } from "./factories/providerRegistry.js";
45
45
  import { FileReferenceRegistry } from "./files/fileReferenceRegistry.js";
46
46
  import { createFileTools } from "./files/fileTools.js";
47
- import { HITLManager } from "./hitl/hitlManager.js";
47
+ import { HITLManager, DEFAULT_ALLOW_MODIFICATION } from "./hitl/hitlManager.js";
48
48
  import { ToolCallBatcher } from "./mcp/batching/index.js";
49
49
  // MCP Enhancement modules - wired into core execution path
50
50
  import { ToolResultCache } from "./mcp/caching/index.js";
@@ -242,7 +242,10 @@ const MEMORY_WRITE_TIMEOUT_MS = 30_000;
242
242
  *
243
243
  * @example With HITL security
244
244
  * ```typescript
245
- * import type { ConfirmationRequestEvent } from '@juspay/neurolink';
245
+ * import type {
246
+ * ConfirmationRequestEvent,
247
+ * ConfirmationResponseEvent,
248
+ * } from '@juspay/neurolink';
246
249
  *
247
250
  * const neurolink = new NeuroLink({
248
251
  * hitl: {
@@ -255,12 +258,20 @@ const MEMORY_WRITE_TIMEOUT_MS = 30_000;
255
258
  * // Approval is event-based; answer each request with the matching id
256
259
  * const emitter = neurolink.getEventEmitter();
257
260
  * emitter.on('hitl:confirmation-request', async (event) => {
261
+ * const receivedAt = Date.now();
258
262
  * const { payload } = event as ConfirmationRequestEvent; // listeners are typed unknown
259
263
  * const approved = await askUser(payload); // your approval UI
260
264
  * emitter.emit('hitl:confirmation-response', {
261
265
  * type: 'hitl:confirmation-response',
262
- * payload: { confirmationId: payload.confirmationId, approved }
263
- * });
266
+ * payload: {
267
+ * confirmationId: payload.confirmationId,
268
+ * approved,
269
+ * metadata: {
270
+ * timestamp: new Date().toISOString(),
271
+ * responseTime: Date.now() - receivedAt // ms since the request arrived
272
+ * }
273
+ * }
274
+ * } satisfies ConfirmationResponseEvent);
264
275
  * });
265
276
  * ```
266
277
  *
@@ -1518,7 +1529,7 @@ export class NeuroLink {
1518
1529
  enabled: config.hitl.enabled,
1519
1530
  dangerousActions: config.hitl.dangerousActions || [],
1520
1531
  timeout: config.hitl.timeout || 30000,
1521
- allowArgumentModification: config.hitl.allowArgumentModification ?? true,
1532
+ allowArgumentModification: config.hitl.allowArgumentModification ?? DEFAULT_ALLOW_MODIFICATION,
1522
1533
  auditLogging: config.hitl.auditLogging ?? false,
1523
1534
  },
1524
1535
  message: "Starting HITL (Human-in-the-Loop) initialization",
@@ -1548,7 +1559,7 @@ export class NeuroLink {
1548
1559
  logger.info(`[NeuroLink] HITL safety features enabled`, {
1549
1560
  dangerousActions: config.hitl.dangerousActions?.length || 0,
1550
1561
  timeout: config.hitl.timeout || 30000,
1551
- allowArgumentModification: config.hitl.allowArgumentModification ?? true,
1562
+ allowArgumentModification: config.hitl.allowArgumentModification ?? DEFAULT_ALLOW_MODIFICATION,
1552
1563
  auditLogging: config.hitl.auditLogging ?? false,
1553
1564
  });
1554
1565
  }
@@ -14035,12 +14046,14 @@ Current user's request: ${currentInput}`;
14035
14046
  * many would have the WHOLE request refused, and NeuroLink's own routing
14036
14047
  * would silently degrade to the heuristic — the host's problem would
14037
14048
  * become NeuroLink's. A provider that declares no `maxQuestions`
14038
- * (TypeSafe, XOR) takes every well-formed addition.
14049
+ * (TypeSafe) takes every well-formed addition. For the same reason a
14050
+ * question the provider cannot answer (a type it does not list, a choice
14051
+ * or score past its cap) is dropped on its own.
14039
14052
  * 4. Answers are split: the consumer sees only its own ids, exactly as it
14040
14053
  * would without hooks; `onAnswers` gets the host's answers under the
14041
14054
  * host's original ids plus the full result.
14042
14055
  *
14043
- * The request's `images` and `video` ride along untouched — `merged` and
14056
+ * The request's `images`, `video` and `audio` ride along untouched — `merged` and
14044
14057
  * the wire call spread the request, and only `state`, the questions and
14045
14058
  * the result are cloned — so an XOR request keeps its Buffers and its
14046
14059
  * result keeps `mediaBytes`.
@@ -14134,7 +14147,7 @@ Current user's request: ${currentInput}`;
14134
14147
  model: merged.model,
14135
14148
  credentials: merged.credentials,
14136
14149
  });
14137
- // A provider without a question-count cap (TypeSafe, XOR) takes
14150
+ // A provider without a question-count cap (TypeSafe) takes
14138
14151
  // every well-formed addition; its token/byte limits still apply
14139
14152
  // and are what `decisionLimits()` reports for sizing.
14140
14153
  const room = limits?.maxQuestions !== undefined
@@ -14144,6 +14157,7 @@ Current user's request: ${currentInput}`;
14144
14157
  ...ownQuestions,
14145
14158
  };
14146
14159
  let invalidCount = 0;
14160
+ let unsupportedCount = 0;
14147
14161
  let overflowCount = 0;
14148
14162
  for (const [hostId, proposal] of proposedEntries) {
14149
14163
  // The wire carries a snapshot of the host's question, never the
@@ -14153,6 +14167,12 @@ Current user's request: ${currentInput}`;
14153
14167
  invalidCount += 1;
14154
14168
  continue;
14155
14169
  }
14170
+ // A question the provider cannot read would have the whole
14171
+ // request refused, NeuroLink's own questions with it.
14172
+ if (unsupportedDecisionQuestion(question, limits) !== undefined) {
14173
+ unsupportedCount += 1;
14174
+ continue;
14175
+ }
14156
14176
  if (hostIdByWireId.size >= room) {
14157
14177
  overflowCount += 1;
14158
14178
  continue;
@@ -14164,6 +14184,9 @@ Current user's request: ${currentInput}`;
14164
14184
  if (invalidCount > 0) {
14165
14185
  logger.warn(`[Decision] dropped ${invalidCount} malformed host question(s) for site "${site}"`);
14166
14186
  }
14187
+ if (unsupportedCount > 0) {
14188
+ logger.warn(`[Decision] dropped ${unsupportedCount} host question(s) for site "${site}" that ${limits?.provider ?? "the decision provider"} cannot answer`);
14189
+ }
14167
14190
  if (overflowCount > 0 && limits) {
14168
14191
  logger.warn(`[Decision] dropped ${overflowCount} host question(s) for site "${site}": ${limits.provider} accepts ${limits.maxQuestions} per request and NeuroLink asks ${ownCount}`);
14169
14192
  }
@@ -14295,6 +14318,7 @@ Current user's request: ${currentInput}`;
14295
14318
  "ai.provider": providerName,
14296
14319
  "decision.question_count": questionCount,
14297
14320
  "decision.images.count": options.images?.length ?? 0,
14321
+ "decision.audio.count": options.audio === undefined ? 0 : 1,
14298
14322
  ...siteSpanAttributes,
14299
14323
  },
14300
14324
  }, async (otelSpan) => {
@@ -14324,6 +14348,7 @@ Current user's request: ${currentInput}`;
14324
14348
  "ai.model": options.model ?? "",
14325
14349
  "decision.question_count": questionCount,
14326
14350
  "decision.images.count": options.images?.length ?? 0,
14351
+ "decision.audio.count": options.audio === undefined ? 0 : 1,
14327
14352
  ...siteSpanAttributes,
14328
14353
  }, parentSpanId, traceId);
14329
14354
  try {
@@ -14337,6 +14362,7 @@ Current user's request: ${currentInput}`;
14337
14362
  questions: options.questions,
14338
14363
  images: options.images,
14339
14364
  video: options.video,
14365
+ audio: options.audio,
14340
14366
  model: options.model,
14341
14367
  signal: options.signal,
14342
14368
  timeoutMs: options.timeoutMs,
@@ -55,6 +55,25 @@
55
55
  "properties": {
56
56
  "maxStateTokens": { "type": "integer", "minimum": 1 },
57
57
  "maxQuestions": { "type": "integer", "minimum": 1 },
58
+ "questionTypes": {
59
+ "type": "array",
60
+ "minItems": 1,
61
+ "uniqueItems": true,
62
+ "items": {
63
+ "enum": ["boolean", "choice", "score", "value", "choice-unknown"]
64
+ },
65
+ "description": "The question types the provider reads; choice-unknown is a choice that sets unknown: true. Absent means boolean, choice and score. openai-decisions may list only boolean, choice and score."
66
+ },
67
+ "maxScoreLevels": {
68
+ "type": "integer",
69
+ "minimum": 1,
70
+ "description": "Most levels one score question may have."
71
+ },
72
+ "maxChoiceOptions": {
73
+ "type": "integer",
74
+ "minimum": 1,
75
+ "description": "Most options one choice question may have."
76
+ },
58
77
  "nonAsciiTokensPerChar": { "type": "number", "exclusiveMinimum": 0 },
59
78
  "digitTokensPerChar": { "type": "number", "exclusiveMinimum": 0 },
60
79
  "symbolTokensPerChar": { "type": "number", "exclusiveMinimum": 0 },
@@ -75,6 +94,19 @@
75
94
  "type": "integer",
76
95
  "minimum": 1,
77
96
  "description": "Ceiling on the encoded request body, in bytes."
97
+ },
98
+ "audio": {
99
+ "type": "object",
100
+ "additionalProperties": false,
101
+ "required": ["maxSeconds"],
102
+ "description": "One audio clip is accepted. Requires decide.media.audioKey; not valid with openai-decisions.",
103
+ "properties": {
104
+ "maxSeconds": {
105
+ "type": "integer",
106
+ "minimum": 1,
107
+ "description": "Seconds of audio the model reads; a WAV, FLAC or Ogg clip that runs longer is refused."
108
+ }
109
+ }
78
110
  }
79
111
  }
80
112
  },
@@ -154,10 +186,32 @@
154
186
  "type": "object",
155
187
  "additionalProperties": false,
156
188
  "required": ["imagesKey"],
157
- "description": "Body keys prepared media is written under, after model, state and questions: imagesKey only when the request carries an image, videoKey only when it carries a video, and the two differ. With dialect system-one it requires decisionLimits.media, and videoKey is required exactly when that allowance's video is true. Not valid with openai-decisions.",
189
+ "description": "Body keys prepared media is written under, after model, state and questions: imagesKey only when the request carries an image, videoKey only when it carries a video, audioKey only when it carries audio, and all differ. With dialect system-one it requires decisionLimits.media; videoKey is required exactly when that allowance's video is true, and audioKey exactly when it sets audio. Not valid with openai-decisions.",
158
190
  "properties": {
159
191
  "imagesKey": { "$ref": "#/definitions/decideMediaKey" },
160
- "videoKey": { "$ref": "#/definitions/decideMediaKey" }
192
+ "videoKey": { "$ref": "#/definitions/decideMediaKey" },
193
+ "audioKey": { "$ref": "#/definitions/decideMediaKey" }
194
+ }
195
+ },
196
+ "capableModel": {
197
+ "type": "object",
198
+ "additionalProperties": false,
199
+ "required": ["model", "questionTypes"],
200
+ "description": "A models.catalog key (not the default) the request is sent to instead of the default when the call names no model, no model variable is set, and the request carries media or a question of one of questionTypes; that model's decisionLimits row is the one checked. questionTypes must be listed in decisionLimits.questionTypes.",
201
+ "properties": {
202
+ "model": { "type": "string", "minLength": 1 },
203
+ "questionTypes": {
204
+ "type": "array",
205
+ "items": {
206
+ "enum": [
207
+ "boolean",
208
+ "choice",
209
+ "score",
210
+ "value",
211
+ "choice-unknown"
212
+ ]
213
+ }
214
+ }
161
215
  }
162
216
  },
163
217
  "ambientKeyGuard": {
@@ -570,6 +624,11 @@
570
624
  "type": "integer",
571
625
  "minimum": 1,
572
626
  "description": "Decide entries only: the per-call budget for decide(), in milliseconds. When omitted, the base provider's default timeout applies."
627
+ },
628
+ "decideMsPerKStateTokens": {
629
+ "type": "integer",
630
+ "minimum": 1,
631
+ "description": "Decide entries only: added to decideMs for every 1,000 estimated state tokens, for a model whose latency grows with the state."
573
632
  }
574
633
  }
575
634
  },
@@ -690,7 +749,9 @@
690
749
  "defaultMaxOutputTokens"
691
750
  ]
692
751
  },
693
- "timeouts": { "properties": { "decideMs": false } }
752
+ "timeouts": {
753
+ "properties": { "decideMs": false, "decideMsPerKStateTokens": false }
754
+ }
694
755
  }
695
756
  },
696
757
  "definitions": {
@@ -540,11 +540,30 @@ const decideMediaSchema = z
540
540
  .strictObject({
541
541
  imagesKey: decideMediaKeySchema,
542
542
  videoKey: decideMediaKeySchema.optional(),
543
+ audioKey: decideMediaKeySchema.optional(),
543
544
  })
544
545
  .refine((media) => media.imagesKey !== media.videoKey, {
545
546
  path: ["videoKey"],
546
547
  message: "decide.media.videoKey must differ from imagesKey: one body key cannot carry both",
548
+ })
549
+ .refine((media) => media.audioKey === undefined ||
550
+ (media.audioKey !== media.imagesKey && media.audioKey !== media.videoKey), {
551
+ path: ["audioKey"],
552
+ message: "decide.media.audioKey must differ from imagesKey and videoKey: one body key cannot carry two kinds of media",
547
553
  });
554
+ const DECIDE_QUESTION_TYPES = [
555
+ "boolean",
556
+ "choice",
557
+ "score",
558
+ "value",
559
+ "choice-unknown",
560
+ ];
561
+ // The openai-decisions dialect writes predicates, choices and levels only.
562
+ const OPENAI_DECISIONS_QUESTION_TYPES = new Set([
563
+ "boolean",
564
+ "choice",
565
+ "score",
566
+ ]);
548
567
  const GUARD_LIST_MAX = 8;
549
568
  const ambientGuardEnvNameSchema = z
550
569
  .string()
@@ -603,6 +622,12 @@ const decideBlockSchema = z.strictObject({
603
622
  authHeader: z.enum(["bearer", "x-api-key"]).optional(),
604
623
  stateEncoding: z.enum(["text"]).optional(),
605
624
  media: decideMediaSchema.optional(),
625
+ capableModel: z
626
+ .strictObject({
627
+ model: z.string().min(1),
628
+ questionTypes: z.array(z.enum(DECIDE_QUESTION_TYPES)),
629
+ })
630
+ .optional(),
606
631
  ambientKeyGuard: ambientKeyGuardSchema.optional(),
607
632
  requestIdHeaders: z.array(decideHeaderNameSchema).optional(),
608
633
  response: z
@@ -653,6 +678,15 @@ const decideModelsSchema = z
653
678
  const decideLimitsSchema = z.strictObject({
654
679
  maxStateTokens: decidePositiveInt,
655
680
  maxQuestions: decidePositiveInt.optional(),
681
+ questionTypes: z
682
+ .array(z.enum(DECIDE_QUESTION_TYPES))
683
+ .min(1)
684
+ .refine((types) => new Set(types).size === types.length, {
685
+ message: "decisionLimits.questionTypes lists the same type twice",
686
+ })
687
+ .optional(),
688
+ maxScoreLevels: decidePositiveInt.optional(),
689
+ maxChoiceOptions: decidePositiveInt.optional(),
656
690
  nonAsciiTokensPerChar: decidePositiveNumber.optional(),
657
691
  digitTokensPerChar: decidePositiveNumber.optional(),
658
692
  symbolTokensPerChar: decidePositiveNumber.optional(),
@@ -668,10 +702,36 @@ const decideLimitsSchema = z.strictObject({
668
702
  maxImages: decidePositiveInt,
669
703
  video: z.boolean(),
670
704
  maxRequestBytes: decidePositiveInt,
705
+ audio: z.strictObject({ maxSeconds: decidePositiveInt }).optional(),
671
706
  })
672
707
  .optional(),
673
708
  advisory: z.boolean().optional(),
674
709
  });
710
+ // decide.capableModel names another key of the catalog, never the default, and
711
+ // only question types the entry reads: anything else is refused before a model
712
+ // is chosen.
713
+ function checkCapableModel(entry, modelKeys, issue) {
714
+ const capable = entry.decide.capableModel;
715
+ if (capable === undefined) {
716
+ return;
717
+ }
718
+ if (!modelKeys.has(capable.model)) {
719
+ issue(["decide", "capableModel", "model"], "decide.capableModel.model must be a key of models.catalog");
720
+ }
721
+ if (capable.model === entry.models.default) {
722
+ issue(["decide", "capableModel", "model"], "decide.capableModel.model must differ from models.default: a request is sent to it instead of the default");
723
+ }
724
+ const declared = entry.decisionLimits?.questionTypes ?? [
725
+ "boolean",
726
+ "choice",
727
+ "score",
728
+ ];
729
+ capable.questionTypes.forEach((type, index) => {
730
+ if (!declared.includes(type)) {
731
+ issue(["decide", "capableModel", "questionTypes", index], `decide.capableModel.questionTypes lists ${type}, which decisionLimits.questionTypes does not: such a question is refused before any model is chosen`);
732
+ }
733
+ });
734
+ }
675
735
  const decideCatalogJsonSchema = z
676
736
  .strictObject({
677
737
  ...catalogIdentityShape,
@@ -680,7 +740,10 @@ const decideCatalogJsonSchema = z
680
740
  wire: decideWireSchema,
681
741
  models: decideModelsSchema,
682
742
  timeouts: z
683
- .strictObject({ decideMs: decidePositiveInt.optional() })
743
+ .strictObject({
744
+ decideMs: decidePositiveInt.optional(),
745
+ decideMsPerKStateTokens: decidePositiveInt.optional(),
746
+ })
684
747
  .optional(),
685
748
  decisionLimits: decideLimitsSchema.optional(),
686
749
  decide: decideBlockSchema,
@@ -743,6 +806,7 @@ const decideCatalogJsonSchema = z
743
806
  if (alias !== undefined && !modelKeys.has(alias)) {
744
807
  issue(["decide", "model", "alias"], "decide.model.alias must be a key of models.catalog");
745
808
  }
809
+ checkCapableModel(entry, modelKeys, issue);
746
810
  for (const id of Object.keys(entry.decisionLimits?.models ?? {})) {
747
811
  if (!modelKeys.has(id)) {
748
812
  issue(["decisionLimits", "models", id], "decisionLimits.models key must be a key of models.catalog");
@@ -763,6 +827,14 @@ const decideCatalogJsonSchema = z
763
827
  if (placement !== "body") {
764
828
  issue(["decide", "model", "placement"], "decide.model.placement must be body with dialect openai-decisions: the model is always a field of the request");
765
829
  }
830
+ if (mediaAllowance?.audio !== undefined) {
831
+ issue(["decisionLimits", "media", "audio"], "decisionLimits.media.audio is not valid with dialect openai-decisions: its request input has no audio part");
832
+ }
833
+ (entry.decisionLimits?.questionTypes ?? []).forEach((type, index) => {
834
+ if (!OPENAI_DECISIONS_QUESTION_TYPES.has(type)) {
835
+ issue(["decisionLimits", "questionTypes", index], `decisionLimits.questionTypes cannot list ${type} with dialect openai-decisions: that dialect writes boolean, choice and score questions only`);
836
+ }
837
+ });
766
838
  }
767
839
  else {
768
840
  if (mediaKeys !== undefined && mediaAllowance === undefined) {
@@ -778,6 +850,14 @@ const decideCatalogJsonSchema = z
778
850
  if (!mediaAllowance.video && mediaKeys.videoKey !== undefined) {
779
851
  issue(["decide", "media", "videoKey"], "decide.media.videoKey must be omitted when decisionLimits.media.video is false");
780
852
  }
853
+ if (mediaAllowance.audio !== undefined &&
854
+ mediaKeys.audioKey === undefined) {
855
+ issue(["decide", "media", "audioKey"], "decide.media.audioKey is required when decisionLimits.media.audio is set");
856
+ }
857
+ if (mediaAllowance.audio === undefined &&
858
+ mediaKeys.audioKey !== undefined) {
859
+ issue(["decide", "media", "audioKey"], "decide.media.audioKey must be omitted when decisionLimits.media.audio is not set");
860
+ }
781
861
  }
782
862
  }
783
863
  if (decide.baseURLRequired === true &&