@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
@@ -10,18 +10,18 @@
10
10
  "topModels": ["xor-latest", "xor-omni"],
11
11
  "catalog": {
12
12
  "xor-latest": {
13
- "contextWindow": 250000,
13
+ "contextWindow": 131072,
14
14
  "maxOutputTokens": 256,
15
15
  "vision": false,
16
16
  "status": "production",
17
- "description": "Recommended - XOR text alias; boolean, choice and score questions, 2 to 64 options each"
17
+ "description": "Recommended - XOR text alias; boolean, choice and score questions over a 131,072-token window. A request with a value question, an image or audio goes to xor-omni when no model was chosen (decide.capableModel)"
18
18
  },
19
19
  "xor-omni": {
20
- "contextWindow": 250000,
20
+ "contextWindow": 32768,
21
21
  "maxOutputTokens": 256,
22
22
  "vision": true,
23
23
  "status": "production",
24
- "description": "XOR multimodal alias; reads images and video alongside the state, for boolean, choice and score questions"
24
+ "description": "XOR multimodal alias; boolean, choice, score and value questions over a 32,768-token window, plus one image and up to 30 seconds of audio"
25
25
  },
26
26
  "xor-1.1": {
27
27
  "contextWindow": 250000,
@@ -32,11 +32,24 @@
32
32
  }
33
33
  }
34
34
  },
35
- "timeouts": { "decideMs": 5000 },
35
+ "timeouts": { "decideMs": 5000, "decideMsPerKStateTokens": 150 },
36
36
  "decisionLimits": {
37
- "maxStateTokens": 200000,
37
+ "maxStateTokens": 28000,
38
+ "maxQuestions": 128,
39
+ "questionTypes": ["boolean", "choice", "score", "value", "choice-unknown"],
40
+ "maxScoreLevels": 10,
41
+ "maxChoiceOptions": 64,
38
42
  "nonAsciiTokensPerChar": 1,
39
- "media": { "maxImages": 8, "video": true, "maxRequestBytes": 8388608 }
43
+ "models": {
44
+ "xor-latest": { "maxStateTokens": 120000 },
45
+ "xor-omni": { "maxStateTokens": 28000 }
46
+ },
47
+ "media": {
48
+ "maxImages": 1,
49
+ "video": false,
50
+ "maxRequestBytes": 25000000,
51
+ "audio": { "maxSeconds": 30 }
52
+ }
40
53
  },
41
54
  "decide": {
42
55
  "dialect": "system-one",
@@ -46,7 +59,8 @@
46
59
  "baseURLShape": "origin",
47
60
  "endpoint": "{baseURL}/v1/decisions",
48
61
  "model": { "placement": "body" },
49
- "media": { "imagesKey": "images", "videoKey": "video" },
62
+ "media": { "imagesKey": "images", "audioKey": "audio" },
63
+ "capableModel": { "model": "xor-omni", "questionTypes": ["value"] },
50
64
  "requestIdHeaders": [
51
65
  "x-request-id",
52
66
  "x-typesafe-request-id",
@@ -63,6 +77,12 @@
63
77
  "messagePattern": "(context|prefill|prompt).{0,40}(length|limit|too long|exceed)|(too long|exceeds?).{0,40}(context|tokens)",
64
78
  "kind": "max_tokens_exceeded"
65
79
  },
80
+ {
81
+ "statusMin": 500,
82
+ "messagePattern": "backend 4[0-9]{2}",
83
+ "kind": "invalid_request",
84
+ "retryable": false
85
+ },
66
86
  { "statusMin": 500, "kind": "server" }
67
87
  ],
68
88
  "default": "invalid_request",
@@ -91,8 +111,8 @@
91
111
  },
92
112
  "evidence": {
93
113
  "rosterVerified": {
94
- "date": "2026-10-18",
95
- "method": "POST <origin>/v1/decisions with model xor-latest and a 3-option choice question returned HTTP 200 with served model xor-text-2, through NeuroLink's decide(); a 65-option choice question returned HTTP 422 reading 2..64 options, got 65. A 64-option question returned 200 and 77 options returned 422 on both aliases when probed by hand the same day. A successful xor-omni answer, and any image, audio or video request, were not exercised. The figures contextWindow 250000, maxOutputTokens 256, the 200000-token state window and the media limits are carried over from xor-1.1, not re-measured for the aliases; xor-1.1 is kept only as a retired name"
114
+ "date": "2026-10-19",
115
+ "method": "Probed POST <origin>/v1/decisions through a LiteLLM gateway with both aliases. xor-latest read a needle at 120,076 input tokens and refused 200,076 as longer than its 131,072-token context; xor-omni refused past 32,768. 128 questions were answered and 256 refused; a choice took 64 options (63 plus unknown; 64 plus unknown crashed with a 500); a score took 10 levels and 11 were refused by the gateway. value answered int, float and pattern-bound strings on xor-omni, and on xor-latest through the gateway's fallback to xor-omni. One image was read (a second was refused with 422) at a fixed ~600 tokens whatever its resolution. Audio as {data, format} was heard in WAV, FLAC, Ogg, Opus and MP3 (an English speech clip identified at 0.9999 in each) at ~25 tokens a second and cut at 30 seconds, keeping the start; M4A/AAC decoded to nothing. A video, sent as a data URL or as {data, format}, returned the same answer as garbage bytes, so the gateway drops it. The body cap was 24 MiB. A malformed image or audio clip and an over-long state returned 502 wrapping the backend's 400"
96
116
  },
97
117
  "liveMatrix": null,
98
118
  "addedInPR": "https://github.com/juspay/neurolink/pull/1984"
@@ -1,4 +1,4 @@
1
- import type { DecideCatalogJson, DecisionDialect, DecisionError, DecisionPreparedMedia, DecisionState, UnknownRecord } from "../types/index.js";
1
+ import type { DecideCatalogJson, DecisionDialect, DecisionError, DecisionPreparedMedia, DecisionRequest, DecisionState, UnknownRecord } from "../types/index.js";
2
2
  import { SystemOneDecisionProvider } from "./systemOneDecision.js";
3
3
  /** A `decide` provider described entirely by its catalog entry. */
4
4
  export declare class ConfiguredDecisionProvider extends SystemOneDecisionProvider {
@@ -14,6 +14,13 @@ export declare class ConfiguredDecisionProvider extends SystemOneDecisionProvide
14
14
  protected missingKeyMessage(): string;
15
15
  protected missingConfigMessage(): string | undefined;
16
16
  protected decisionEndpoint(): string;
17
+ /**
18
+ * `decide.capableModel` in place of the default, for a request that carries
19
+ * media or one of its question types, when nobody chose a model: the call
20
+ * names none, the model variable is unset, and the instance is on the
21
+ * default. A model somebody chose is always the one sent.
22
+ */
23
+ protected resolveRequestModel(request: DecisionRequest): string;
17
24
  protected decisionHeaders(): Record<string, string>;
18
25
  /**
19
26
  * The state the dialect is given. Only an entry that sets `stateEncoding`
@@ -1,3 +1,4 @@
1
+ import { decisionQuestionTypeOf } from "../utils/decisionLimits.js";
1
2
  import { logger } from "../utils/logger.js";
2
3
  import { redactUrlForError } from "../utils/logSanitize.js";
3
4
  import { getProviderModel } from "../utils/providerConfig.js";
@@ -70,6 +71,27 @@ export class ConfiguredDecisionProvider extends SystemOneDecisionProvider {
70
71
  decisionEndpoint() {
71
72
  return this.endpoint;
72
73
  }
74
+ /**
75
+ * `decide.capableModel` in place of the default, for a request that carries
76
+ * media or one of its question types, when nobody chose a model: the call
77
+ * names none, the model variable is unset, and the instance is on the
78
+ * default. A model somebody chose is always the one sent.
79
+ */
80
+ resolveRequestModel(request) {
81
+ const chosen = super.resolveRequestModel(request);
82
+ const capable = this.entry.decide.capableModel;
83
+ if (capable === undefined ||
84
+ request.model !== undefined ||
85
+ chosen !== this.entry.models.default ||
86
+ readText(process.env[catalogEnvVar(this.entry, "model")]) !== "") {
87
+ return chosen;
88
+ }
89
+ const carriesMedia = (request.images?.length ?? 0) > 0 ||
90
+ request.video !== undefined ||
91
+ request.audio !== undefined;
92
+ const asksFor = Object.values(request.questions).some((question) => capable.questionTypes.includes(decisionQuestionTypeOf(question)));
93
+ return carriesMedia || asksFor ? capable.model : chosen;
94
+ }
73
95
  decisionHeaders() {
74
96
  const credential = this.entry.decide.authHeader === "x-api-key"
75
97
  ? { "x-api-key": this.apiKey }
@@ -30,6 +30,11 @@ function encodeQuestion(question) {
30
30
  })),
31
31
  };
32
32
  }
33
+ if (question.type === "value") {
34
+ // Unreachable through decide(): an entry on this dialect cannot list
35
+ // `value` in its questionTypes, so the pre-flight check refuses it first.
36
+ throw new Error("The openai-decisions dialect has no value question; it writes boolean, choice and score questions only.");
37
+ }
33
38
  // The index is the label so a returned `value` and `label` can never
34
39
  // disagree about which level it is; the rubric text rides in `description`.
35
40
  return {
@@ -140,6 +145,9 @@ function readAnswer(question, raw) {
140
145
  if (question.type === "choice") {
141
146
  return raw.type === "choice" ? readChoice(question, raw) : null;
142
147
  }
148
+ if (question.type === "value") {
149
+ return null;
150
+ }
143
151
  return raw.type === "score" ? readScore(question, raw) : null;
144
152
  }
145
153
  /** Number of answers carrying each asked name, so a repeated one can be refused. */
@@ -1,4 +1,4 @@
1
- import { asNumber, asNumberMap, asStringMap, isRecord } from "../guards.js";
1
+ import { asNumber, asNumberMap, asTextMap, isRecord } from "../guards.js";
2
2
  /**
3
3
  * Last-resort confidence, when no transport reported one.
4
4
  *
@@ -43,11 +43,12 @@ function parseDecisionAnswer(raw, reportedConfidence) {
43
43
  reportedConfidence ??
44
44
  deriveConfidence(probabilities),
45
45
  probabilities,
46
+ ...(raw.unknown === true ? { unknown: true } : {}),
46
47
  };
47
48
  }
48
49
  case "score": {
49
50
  const score = asNumber(raw.score);
50
- const legend = asStringMap(raw.legend);
51
+ const legend = asTextMap(raw.legend);
51
52
  const probabilities = asNumberMap(raw.probabilities);
52
53
  if (score === undefined || !legend || !probabilities) {
53
54
  return null;
@@ -62,6 +63,21 @@ function parseDecisionAnswer(raw, reportedConfidence) {
62
63
  probabilities,
63
64
  };
64
65
  }
66
+ case "value": {
67
+ const value = raw.value === null || typeof raw.value === "string"
68
+ ? raw.value
69
+ : asNumber(raw.value);
70
+ const confidence = asNumber(raw.confidence) ?? reportedConfidence;
71
+ if (value === undefined || confidence === undefined) {
72
+ return null;
73
+ }
74
+ return {
75
+ type: "value",
76
+ value,
77
+ confidence,
78
+ ...(raw.unknown === true ? { unknown: true } : {}),
79
+ };
80
+ }
65
81
  default:
66
82
  return null;
67
83
  }
@@ -78,6 +94,13 @@ function encodeQuestion(question) {
78
94
  ...(question.criteria ? { criteria: question.criteria } : {}),
79
95
  };
80
96
  }
97
+ if (question.type === "choice" && question.unknown !== undefined) {
98
+ // `unknown` goes out only when it is on: a server that does not know the
99
+ // field may refuse it even as false. A choice without it is the caller's
100
+ // own object, as every other question is.
101
+ const { unknown, ...rest } = question;
102
+ return unknown ? { ...rest, unknown: true } : rest;
103
+ }
81
104
  return question;
82
105
  }
83
106
  export const systemOneDialect = {
@@ -98,6 +121,9 @@ export const systemOneDialect = {
98
121
  ...(media.video && mediaKeys.videoKey
99
122
  ? { [mediaKeys.videoKey]: media.video }
100
123
  : {}),
124
+ ...(media.audio && mediaKeys.audioKey
125
+ ? { [mediaKeys.audioKey]: media.audio }
126
+ : {}),
101
127
  };
102
128
  },
103
129
  readAnswers: (decoded, reportedConfidence) => {
@@ -2,3 +2,9 @@ export declare const isRecord: (v: unknown) => v is Record<string, unknown>;
2
2
  export declare const asNumber: (v: unknown) => number | undefined;
3
3
  export declare const asNumberMap: (v: unknown) => Record<string, number> | undefined;
4
4
  export declare const asStringMap: (v: unknown) => Record<string, string> | undefined;
5
+ /**
6
+ * A map whose values are shown as text: a string as is, anything else a JSON
7
+ * value can hold as its JSON text. A score legend echoes each level as the
8
+ * caller gave it, and a level may be a number or an object.
9
+ */
10
+ export declare const asTextMap: (v: unknown) => Record<string, string> | undefined;
@@ -27,3 +27,25 @@ export const asStringMap = (v) => {
27
27
  }
28
28
  return out;
29
29
  };
30
+ /**
31
+ * A map whose values are shown as text: a string as is, anything else a JSON
32
+ * value can hold as its JSON text. A score legend echoes each level as the
33
+ * caller gave it, and a level may be a number or an object.
34
+ */
35
+ export const asTextMap = (v) => {
36
+ if (!isRecord(v)) {
37
+ return undefined;
38
+ }
39
+ const out = {};
40
+ for (const [k, raw] of Object.entries(v)) {
41
+ if (typeof raw === "string") {
42
+ out[k] = raw;
43
+ continue;
44
+ }
45
+ if (raw === undefined || typeof raw === "function") {
46
+ return undefined;
47
+ }
48
+ out[k] = JSON.stringify(raw);
49
+ }
50
+ return out;
51
+ };
@@ -22,6 +22,12 @@ export declare function describeValidationErrors(detail: readonly unknown[]): st
22
22
  * belongs in an error message or a log, whatever the wording, so the
23
23
  * configured key, anything shaped like a LiteLLM key, any long hex run and any
24
24
  * embedded `data:` URL are all removed.
25
+ *
26
+ * Its request-validation errors also quote the request back (pydantic's
27
+ * `input_value=…`, the start of the state and questions), and its routing
28
+ * errors append the gateway's fallback table, which names every model group it
29
+ * serves. The first is the caller's data and the second is the gateway's, so
30
+ * both are cut.
25
31
  */
26
32
  export declare function redactCredentials(message: string, apiKey: string): string;
27
33
  /**
@@ -85,12 +91,14 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
85
91
  */
86
92
  protected readRetryAfterMs(_headers: Headers): number | undefined;
87
93
  /**
88
- * The per-attempt timeout when the caller sets none. A provider whose latency
89
- * grows with the number of questions or the size of the state scales the
90
- * descriptor's allowance here. `stateTokens` is the local estimate, 0 when the
91
- * descriptor sets no local limits.
94
+ * The per-attempt timeout when the caller sets none: the descriptor's
95
+ * `decideMs`, plus `decideMsPerKStateTokens` for every full 1,000 estimated
96
+ * state tokens when it declares that, since a long state takes longer to read
97
+ * and a flat budget would cut off a request well inside the model's window. A
98
+ * provider whose latency grows another way overrides this. `stateTokens` is
99
+ * the local estimate, 0 when the descriptor sets no local limits.
92
100
  */
93
- protected defaultTimeoutMs(_questionCount: number, _stateTokens: number): number | undefined;
101
+ protected defaultTimeoutMs(_questionCount: number, stateTokens: number): number | undefined;
94
102
  /**
95
103
  * The part of a successful response that holds `answers`, `usage` and
96
104
  * `model`. The default is the response itself; a vendor that wraps every
@@ -101,6 +109,12 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
101
109
  /** Per-question confidence a transport reports outside the answer objects. */
102
110
  protected reportedConfidence(_payload: unknown): Record<string, number>;
103
111
  protected resolveResponseModel(payload: Record<string, unknown>, requestedModel: string): string;
112
+ /**
113
+ * The model this request goes to and is checked against: the call's own, else
114
+ * the instance's. A provider whose default cannot read some requests may pick
115
+ * another for them here.
116
+ */
117
+ protected resolveRequestModel(request: DecisionRequest): string;
104
118
  /** Extra fields for the credential-rejected warning. */
105
119
  protected diagnosticContext(): Record<string, unknown>;
106
120
  protected getProviderName(): AIProviderName;
@@ -126,8 +140,17 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
126
140
  */
127
141
  decide(request: DecisionRequest): Promise<DecisionResult>;
128
142
  /**
129
- * Turn the request's images and video into data URLs, or refuse it. Refused
130
- * as a non-retryable `invalid_request`: resending the same media cannot help.
143
+ * Refuse a question this provider does not read: a type it does not list in
144
+ * `decisionLimits.questionTypes` (absent means boolean, choice and score), or
145
+ * a choice or score past its per-question cap. Checked even when the limits
146
+ * are advisory, since a server that does not know a question type cannot
147
+ * answer it.
148
+ */
149
+ private assertSupportedQuestions;
150
+ /**
151
+ * Turn the request's images, video and audio into wire form, or refuse it.
152
+ * Refused as a non-retryable `invalid_request`: resending the same media
153
+ * cannot help.
131
154
  */
132
155
  private prepareMedia;
133
156
  /**
@@ -6,7 +6,7 @@ import { ProviderError } from "../types/index.js";
6
6
  import { prepareDecisionMedia } from "../utils/decisionMedia.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { redactUrlsInText } from "../utils/logSanitize.js";
9
- import { estimateDecisionStateTokens, resolveDecisionLimitsReading, } from "../utils/decisionLimits.js";
9
+ import { estimateDecisionStateTokens, resolveDecisionLimitsReading, unsupportedDecisionQuestion, } from "../utils/decisionLimits.js";
10
10
  import { systemOneDialect } from "./decide/dialects/systemOne.js";
11
11
  import { asNumber, isRecord } from "./decide/guards.js";
12
12
  /**
@@ -44,17 +44,31 @@ export function describeValidationErrors(detail) {
44
44
  * belongs in an error message or a log, whatever the wording, so the
45
45
  * configured key, anything shaped like a LiteLLM key, any long hex run and any
46
46
  * embedded `data:` URL are all removed.
47
+ *
48
+ * Its request-validation errors also quote the request back (pydantic's
49
+ * `input_value=…`, the start of the state and questions), and its routing
50
+ * errors append the gateway's fallback table, which names every model group it
51
+ * serves. The first is the caller's data and the second is the gateway's, so
52
+ * both are cut.
47
53
  */
48
54
  export function redactCredentials(message, apiKey) {
49
55
  const withoutKey = apiKey
50
56
  ? message.split(apiKey).join("[redacted]")
51
57
  : message;
52
- return withoutKey
58
+ return (withoutKey
53
59
  .replace(/\.?\s*Received API Key\s*=[\s\S]*$/, "")
60
+ .replace(/\.?\s*Received Model Group=[\s\S]*$/, "")
61
+ .replace(/\s*No fallback model group found[\s\S]*$/, "")
62
+ // Greedy: the quoted value may itself contain ", input_type=", so only the
63
+ // last one is a delimiter the server wrote.
64
+ .replace(/input_value=[\s\S]*(?=, input_type=)/g, "input_value=[redacted]")
65
+ // Cut short by the server, a quoted value has no input_type after it, and
66
+ // may run over several lines; everything after it is the value.
67
+ .replace(/input_value=(?!\[redacted\])[\s\S]*/g, "input_value=[redacted]")
54
68
  .replace(/\bsk-[A-Za-z0-9._-]+/g, "[redacted]")
55
69
  .replace(/\b[0-9a-f]{32,}\b/gi, "[redacted]")
56
70
  .replace(/data:[a-z]+\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi, "[media]")
57
- .trim();
71
+ .trim());
58
72
  }
59
73
  /**
60
74
  * Whether a failed `fetch` was one that refused a redirect. undici words every
@@ -151,13 +165,20 @@ export class SystemOneDecisionProvider extends BaseProvider {
151
165
  return undefined;
152
166
  }
153
167
  /**
154
- * The per-attempt timeout when the caller sets none. A provider whose latency
155
- * grows with the number of questions or the size of the state scales the
156
- * descriptor's allowance here. `stateTokens` is the local estimate, 0 when the
157
- * descriptor sets no local limits.
168
+ * The per-attempt timeout when the caller sets none: the descriptor's
169
+ * `decideMs`, plus `decideMsPerKStateTokens` for every full 1,000 estimated
170
+ * state tokens when it declares that, since a long state takes longer to read
171
+ * and a flat budget would cut off a request well inside the model's window. A
172
+ * provider whose latency grows another way overrides this. `stateTokens` is
173
+ * the local estimate, 0 when the descriptor sets no local limits.
158
174
  */
159
- defaultTimeoutMs(_questionCount, _stateTokens) {
160
- return this.getDescriptorDecideMs();
175
+ defaultTimeoutMs(_questionCount, stateTokens) {
176
+ const base = this.getDescriptorDecideMs();
177
+ const perK = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts
178
+ ?.decideMsPerKStateTokens;
179
+ return base === undefined || perK === undefined
180
+ ? base
181
+ : base + Math.floor(stateTokens / 1000) * perK;
161
182
  }
162
183
  /**
163
184
  * The part of a successful response that holds `answers`, `usage` and
@@ -175,6 +196,14 @@ export class SystemOneDecisionProvider extends BaseProvider {
175
196
  resolveResponseModel(payload, requestedModel) {
176
197
  return typeof payload.model === "string" ? payload.model : requestedModel;
177
198
  }
199
+ /**
200
+ * The model this request goes to and is checked against: the call's own, else
201
+ * the instance's. A provider whose default cannot read some requests may pick
202
+ * another for them here.
203
+ */
204
+ resolveRequestModel(request) {
205
+ return request.model ?? this.modelName ?? this.getDefaultModel();
206
+ }
178
207
  /** Extra fields for the credential-rejected warning. */
179
208
  diagnosticContext() {
180
209
  return {};
@@ -250,7 +279,8 @@ export class SystemOneDecisionProvider extends BaseProvider {
250
279
  retryable: false,
251
280
  });
252
281
  }
253
- const resolvedModel = request.model ?? this.modelName ?? this.getDefaultModel();
282
+ const resolvedModel = this.resolveRequestModel(request);
283
+ this.assertSupportedQuestions(request, resolvedModel);
254
284
  const stateTokens = this.assertWithinDecisionLimits(request, resolvedModel, questionEntries.length);
255
285
  const wireQuestions = Object.fromEntries(questionEntries.map(([id, question]) => [
256
286
  id,
@@ -393,12 +423,39 @@ export class SystemOneDecisionProvider extends BaseProvider {
393
423
  });
394
424
  }
395
425
  /**
396
- * Turn the request's images and video into data URLs, or refuse it. Refused
397
- * as a non-retryable `invalid_request`: resending the same media cannot help.
426
+ * Refuse a question this provider does not read: a type it does not list in
427
+ * `decisionLimits.questionTypes` (absent means boolean, choice and score), or
428
+ * a choice or score past its per-question cap. Checked even when the limits
429
+ * are advisory, since a server that does not know a question type cannot
430
+ * answer it.
431
+ */
432
+ assertSupportedQuestions(request, model) {
433
+ const descriptor = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName);
434
+ const limits = descriptor
435
+ ? resolveDecisionLimitsReading(descriptor, model)
436
+ : null;
437
+ for (const [id, question] of Object.entries(request.questions)) {
438
+ const problem = unsupportedDecisionQuestion(question, limits);
439
+ if (problem !== undefined) {
440
+ throw this.decisionError({
441
+ kind: "invalid_request",
442
+ message: `${this.vendorLabel()} cannot answer question "${id}": ${problem}.`,
443
+ retryable: false,
444
+ });
445
+ }
446
+ }
447
+ }
448
+ /**
449
+ * Turn the request's images, video and audio into wire form, or refuse it.
450
+ * Refused as a non-retryable `invalid_request`: resending the same media
451
+ * cannot help.
398
452
  */
399
453
  async prepareMedia(request) {
400
454
  const limits = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.decisionLimits;
401
- const prepared = await prepareDecisionMedia(request, limits?.media, this.vendorLabel(), listMediaDecisionProviders({ video: request.video !== undefined }));
455
+ const prepared = await prepareDecisionMedia(request, limits?.media, this.vendorLabel(), listMediaDecisionProviders({
456
+ video: request.video !== undefined,
457
+ audio: request.audio !== undefined,
458
+ }));
402
459
  if (prepared.status === "refused") {
403
460
  throw this.decisionError({
404
461
  kind: "invalid_request",
@@ -423,7 +480,7 @@ export class SystemOneDecisionProvider extends BaseProvider {
423
480
  if (bytes > limit) {
424
481
  throw this.decisionError({
425
482
  kind: "invalid_request",
426
- message: `The request is ${bytes} bytes; ${this.vendorLabel()} accepts at most ${limit}. Send fewer or smaller images${media?.video ? ", or a shorter video" : ""}.`,
483
+ message: `The request is ${bytes} bytes; ${this.vendorLabel()} accepts at most ${limit}. Send fewer or smaller images${media?.video ? ", or a shorter video" : ""}${media?.audio ? ", or a shorter audio clip" : ""}.`,
427
484
  retryable: false,
428
485
  });
429
486
  }
@@ -1312,6 +1312,8 @@ export type CliDecideArgs = {
1312
1312
  image?: string[];
1313
1313
  /** One video file (or data: URL) for a provider that reads video. */
1314
1314
  video?: string;
1315
+ /** One audio file (or data: URL) for a provider that reads audio. */
1316
+ audio?: string;
1315
1317
  provider?: string;
1316
1318
  model?: string;
1317
1319
  timeout?: number;