@juspay/neurolink 12.55.3 → 12.56.1

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 (41) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/browser/neurolink.min.js +415 -415
  3. package/dist/cli/commands/setup.d.ts +3 -3
  4. package/dist/cli/commands/setup.js +4 -5
  5. package/dist/constants/enums.d.ts +4 -9
  6. package/dist/constants/enums.js +5 -10
  7. package/dist/factories/providerDescriptors.js +7 -48
  8. package/dist/factories/providerRegistry.js +1 -9
  9. package/dist/models/manifestRegistry.js +0 -2
  10. package/dist/neurolink.d.ts +12 -8
  11. package/dist/neurolink.js +89 -25
  12. package/dist/providers/catalog/index.generated.d.ts +1 -1
  13. package/dist/providers/catalog/index.generated.js +6 -2
  14. package/dist/providers/catalog/provider-catalog.schema.json +54 -1
  15. package/dist/providers/catalog/schema.js +154 -8
  16. package/dist/providers/catalog/xor.json +86 -0
  17. package/dist/providers/configuredDecision.d.ts +2 -2
  18. package/dist/providers/configuredDecision.js +22 -5
  19. package/dist/providers/decide/baseUrl.d.ts +3 -1
  20. package/dist/providers/decide/baseUrl.js +43 -0
  21. package/dist/providers/decide/errors.js +42 -16
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/index.js +1 -0
  24. package/dist/types/ollamaDiagnostics.d.ts +31 -0
  25. package/dist/types/ollamaDiagnostics.js +1 -0
  26. package/dist/types/providerCatalog.d.ts +27 -1
  27. package/dist/types/providerCatalog.generated.d.ts +2 -2
  28. package/dist/types/providers.d.ts +4 -10
  29. package/dist/utils/modelChoices.js +1 -9
  30. package/dist/utils/ollamaDiagnostics.d.ts +69 -0
  31. package/dist/utils/ollamaDiagnostics.js +192 -0
  32. package/dist/utils/providerConfig.d.ts +0 -5
  33. package/dist/utils/providerConfig.js +0 -17
  34. package/dist/utils/providerHealth.d.ts +33 -19
  35. package/dist/utils/providerHealth.js +224 -85
  36. package/docs-site/static/search-index.json +7 -7
  37. package/package.json +3 -1
  38. package/dist/models/manifests/xor.d.ts +0 -12
  39. package/dist/models/manifests/xor.js +0 -30
  40. package/dist/providers/xor.d.ts +0 -48
  41. package/dist/providers/xor.js +0 -192
@@ -60,6 +60,24 @@
60
60
  "symbolTokensPerChar": { "type": "number", "exclusiveMinimum": 0 },
61
61
  "astralTokensPerChar": { "type": "number", "exclusiveMinimum": 0 },
62
62
  "advisory": { "type": "boolean" },
63
+ "media": {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "required": ["maxImages", "video", "maxRequestBytes"],
67
+ "description": "What a request may carry besides text. Requires decide.media, which names the body keys the prepared media is written under.",
68
+ "properties": {
69
+ "maxImages": { "type": "integer", "minimum": 1 },
70
+ "video": {
71
+ "type": "boolean",
72
+ "description": "Whether one video is accepted. True requires decide.media.videoKey; false forbids it."
73
+ },
74
+ "maxRequestBytes": {
75
+ "type": "integer",
76
+ "minimum": 1,
77
+ "description": "Ceiling on the encoded request body, in bytes."
78
+ }
79
+ }
80
+ },
63
81
  "models": {
64
82
  "type": "object",
65
83
  "description": "Per-model limits keyed by a models.catalog key (enforced by the zod schema).",
@@ -96,7 +114,12 @@
96
114
  },
97
115
  "baseURLNormalize": {
98
116
  "type": "array",
99
- "items": { "enum": ["strip-trailing-slash"] }
117
+ "items": { "enum": ["strip-trailing-slash", "strip-v1-suffix"] },
118
+ "description": "Applied to the base URL in list order. strip-v1-suffix removes one trailing /v1, in either case."
119
+ },
120
+ "baseURLShape": {
121
+ "enum": ["origin"],
122
+ "description": "Refuses a normalised base URL that is not an http(s) URL without userinfo, a query string or a fragment. Omitted means no check."
100
123
  },
101
124
  "endpoint": {
102
125
  "type": "string",
@@ -116,6 +139,16 @@
116
139
  }
117
140
  }
118
141
  },
142
+ "media": {
143
+ "type": "object",
144
+ "additionalProperties": false,
145
+ "required": ["imagesKey"],
146
+ "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. Requires decisionLimits.media; videoKey is required exactly when that allowance's video is true.",
147
+ "properties": {
148
+ "imagesKey": { "$ref": "#/definitions/decideMediaKey" },
149
+ "videoKey": { "$ref": "#/definitions/decideMediaKey" }
150
+ }
151
+ },
119
152
  "requestIdHeaders": {
120
153
  "type": "array",
121
154
  "items": {
@@ -156,6 +189,12 @@
156
189
  ]
157
190
  },
158
191
  "statusMin": { "$ref": "#/definitions/httpStatus" },
192
+ "messagePattern": {
193
+ "type": "string",
194
+ "minLength": 1,
195
+ "maxLength": 200,
196
+ "description": "The rule also needs the error message to match this case-insensitive regular expression (first 2,000 characters, key removed). Bounded: outside a character class no unescaped *, + or {n,}, no quantifier on a group, no count above 255, and the counts side by side multiply to at most 4,096 ways (each ? is 2, each {n,m} is m-n+1)."
197
+ },
159
198
  "kind": { "$ref": "#/definitions/decisionErrorKind" },
160
199
  "retryable": { "type": "boolean" }
161
200
  }
@@ -595,6 +634,20 @@
595
634
  },
596
635
  "definitions": {
597
636
  "httpStatus": { "type": "integer", "minimum": 100, "maximum": 599 },
637
+ "decideMediaKey": {
638
+ "type": "string",
639
+ "pattern": "^[A-Za-z][A-Za-z0-9_]*$",
640
+ "not": {
641
+ "enum": [
642
+ "model",
643
+ "state",
644
+ "questions",
645
+ "__proto__",
646
+ "constructor",
647
+ "prototype"
648
+ ]
649
+ }
650
+ },
598
651
  "decisionErrorKind": {
599
652
  "enum": [
600
653
  "authentication",
@@ -397,11 +397,97 @@ const decideErrorKindSchema = z.enum([
397
397
  "timeout",
398
398
  "network",
399
399
  ]);
400
+ // The most steps one `{n}` or `{n,m}` may stand for.
401
+ const MAX_PATTERN_COUNT = 255;
402
+ // The most ways a pattern's counts may multiply: each `?` is 2 and each `{n,m}`
403
+ // is m-n+1. Adjacent bounded counts multiply on a miss (`.{0,255}` three times
404
+ // took 38 s on one 2,000-character message), and a miss on a 5xx is retried.
405
+ // XOR's pattern is 3,362.
406
+ const MAX_PATTERN_WAYS = 4096;
407
+ // What can make a pattern backtrack catastrophically on an error message, so
408
+ // that a catalog author cannot write it: `*`, `+` and an open-ended `{n,}` have
409
+ // no upper bound; any quantifier on a group (`(a|a){0,30}`, `(ab)?`) repeats
410
+ // whatever its alternatives match, and quantifiers nested that way multiply; a
411
+ // count above MAX_PATTERN_COUNT is as good as no bound. A quantifier on a plain
412
+ // character, class or escape is fine. Inside a character class all of them are
413
+ // plain characters, and a backslash makes the next one plain everywhere.
414
+ // Counts that sit side by side multiply, so their product has a budget too
415
+ // (MAX_PATTERN_WAYS); within it a pattern is still not a guarantee of linear
416
+ // time, and wants reading.
417
+ function hasRiskyQuantifier(pattern) {
418
+ const count = /\{(\d+)(?:,(\d*))?\}/y;
419
+ let inClass = false;
420
+ let afterGroup = false;
421
+ let ways = 1;
422
+ for (let index = 0; index < pattern.length; index += 1) {
423
+ const char = pattern[index];
424
+ const onGroup = afterGroup;
425
+ afterGroup = false;
426
+ if (char === "\\") {
427
+ index += 1;
428
+ }
429
+ else if (inClass) {
430
+ inClass = char !== "]";
431
+ }
432
+ else if (char === "[") {
433
+ inClass = true;
434
+ }
435
+ else if (char === ")") {
436
+ afterGroup = true;
437
+ }
438
+ else if (char === "*" || char === "+" || (char === "?" && onGroup)) {
439
+ return true;
440
+ }
441
+ else if (char === "?") {
442
+ // The `?` that opens a group (`(?:`, `(?=`) is not a quantifier.
443
+ if (pattern[index - 1] !== "(") {
444
+ ways *= 2;
445
+ }
446
+ }
447
+ else if (char === "{") {
448
+ count.lastIndex = index;
449
+ const found = count.exec(pattern);
450
+ if (found &&
451
+ (onGroup ||
452
+ found[2] === "" ||
453
+ Math.max(Number(found[1]), Number(found[2] ?? 0)) > MAX_PATTERN_COUNT)) {
454
+ return true;
455
+ }
456
+ if (found && found[2] !== undefined) {
457
+ ways *= Number(found[2]) - Number(found[1]) + 1;
458
+ }
459
+ }
460
+ if (ways > MAX_PATTERN_WAYS) {
461
+ return true;
462
+ }
463
+ }
464
+ return false;
465
+ }
466
+ function compilesAsPattern(pattern) {
467
+ try {
468
+ new RegExp(pattern, "i");
469
+ return true;
470
+ }
471
+ catch {
472
+ return false;
473
+ }
474
+ }
475
+ const decideMessagePatternSchema = z
476
+ .string()
477
+ .min(1)
478
+ .max(200)
479
+ .refine(compilesAsPattern, {
480
+ message: "messagePattern is not a valid regular expression",
481
+ })
482
+ .refine((pattern) => !hasRiskyQuantifier(pattern), {
483
+ message: "messagePattern must be bounded: an unescaped * or +, an open-ended {n,}, a {n,m} count above 255, or any quantifier on a group, outside a character class, can backtrack catastrophically on an error message. Put ?, or {n,m} up to 255, only on a plain character, a character class or an escape, and keep the counts side by side to 4096 ways in all (each ? is 2, each {n,m} is m-n+1)",
484
+ });
400
485
  const decideErrorRuleSchema = z.strictObject({
401
486
  status: z
402
487
  .union([decideStatusSchema, z.array(decideStatusSchema).min(1)])
403
488
  .optional(),
404
489
  statusMin: decideStatusSchema.optional(),
490
+ messagePattern: decideMessagePatternSchema.optional(),
405
491
  kind: decideErrorKindSchema,
406
492
  retryable: z.boolean().optional(),
407
493
  });
@@ -434,16 +520,45 @@ const decideBodyShapeSchema = z
434
520
  path: ["formatter"],
435
521
  message: "a body shape sets isArray: true together with a formatter, or neither: an array without a formatter never matches, and a formatter on a body that is not an array reads the text as an empty list",
436
522
  });
523
+ // The keys the body already uses, and the names an object must not take as an
524
+ // own key from data.
525
+ const RESERVED_MEDIA_KEYS = new Set([
526
+ "model",
527
+ "state",
528
+ "questions",
529
+ "__proto__",
530
+ "constructor",
531
+ "prototype",
532
+ ]);
533
+ const decideMediaKeySchema = z
534
+ .string()
535
+ .regex(/^[A-Za-z][A-Za-z0-9_]*$/, "a media key must start with a letter and use only letters, digits and underscores")
536
+ .refine((key) => !RESERVED_MEDIA_KEYS.has(key), {
537
+ message: "a media key must not be model, state, questions, __proto__, constructor or prototype",
538
+ });
539
+ const decideMediaSchema = z
540
+ .strictObject({
541
+ imagesKey: decideMediaKeySchema,
542
+ videoKey: decideMediaKeySchema.optional(),
543
+ })
544
+ .refine((media) => media.imagesKey !== media.videoKey, {
545
+ path: ["videoKey"],
546
+ message: "decide.media.videoKey must differ from imagesKey: one body key cannot carry both",
547
+ });
437
548
  const decideBlockSchema = z.strictObject({
438
549
  dialect: z.enum(["system-one"]),
439
550
  order: decidePositiveInt,
440
551
  baseURLRequired: z.boolean().optional(),
441
- baseURLNormalize: z.array(z.enum(["strip-trailing-slash"])).optional(),
552
+ baseURLNormalize: z
553
+ .array(z.enum(["strip-trailing-slash", "strip-v1-suffix"]))
554
+ .optional(),
555
+ baseURLShape: z.enum(["origin"]).optional(),
442
556
  endpoint: decideEndpointSchema,
443
557
  model: z.strictObject({
444
558
  placement: z.enum(["body", "body-unless-alias"]),
445
559
  alias: z.string().min(1).optional(),
446
560
  }),
561
+ media: decideMediaSchema.optional(),
447
562
  requestIdHeaders: z.array(decideHeaderNameSchema).optional(),
448
563
  response: z
449
564
  .strictObject({ modelPaths: z.array(decidePathSchema).optional() })
@@ -487,7 +602,9 @@ const decideModelsSchema = z
487
602
  }
488
603
  }
489
604
  });
490
- // `media` is deliberately absent: no decide provider migrated yet needs it.
605
+ // `media` is the allowance the base class prepares and gates a request's images
606
+ // and video against; `decide.media` names the body keys they go out under, and
607
+ // the entry refinement below requires the two together.
491
608
  const decideLimitsSchema = z.strictObject({
492
609
  maxStateTokens: decidePositiveInt,
493
610
  maxQuestions: decidePositiveInt.optional(),
@@ -501,6 +618,13 @@ const decideLimitsSchema = z.strictObject({
501
618
  nonAsciiTokensPerChar: decidePositiveNumber.optional(),
502
619
  }))
503
620
  .optional(),
621
+ media: z
622
+ .strictObject({
623
+ maxImages: decidePositiveInt,
624
+ video: z.boolean(),
625
+ maxRequestBytes: decidePositiveInt,
626
+ })
627
+ .optional(),
504
628
  advisory: z.boolean().optional(),
505
629
  });
506
630
  const decideCatalogJsonSchema = z
@@ -523,6 +647,8 @@ const decideCatalogJsonSchema = z
523
647
  const issue = (path, message) => {
524
648
  ctx.addIssue({ code: "custom", path, message });
525
649
  };
650
+ // Two rules collide only on the same status AND the same pattern: XOR
651
+ // lists a pattern rule and a plain rule at one statusMin.
526
652
  const seenStatus = new Set();
527
653
  const seenStatusMin = new Set();
528
654
  decide.errors.rules.forEach((rule, index) => {
@@ -531,17 +657,21 @@ const decideCatalogJsonSchema = z
531
657
  issue(at, "errors.rules entry needs exactly one of status or statusMin");
532
658
  }
533
659
  const statuses = typeof rule.status === "number" ? [rule.status] : (rule.status ?? []);
660
+ const sameAs = rule.messagePattern === undefined
661
+ ? ""
662
+ : " with the same messagePattern";
663
+ const keyOf = (value) => JSON.stringify([value, rule.messagePattern ?? null]);
534
664
  for (const status of statuses) {
535
- if (seenStatus.has(status)) {
536
- issue([...at, "status"], `errors.rules lists status ${status} more than once`);
665
+ if (seenStatus.has(keyOf(status))) {
666
+ issue([...at, "status"], `errors.rules lists status ${status} more than once${sameAs}`);
537
667
  }
538
- seenStatus.add(status);
668
+ seenStatus.add(keyOf(status));
539
669
  }
540
670
  if (rule.statusMin !== undefined) {
541
- if (seenStatusMin.has(rule.statusMin)) {
542
- issue([...at, "statusMin"], `errors.rules lists statusMin ${rule.statusMin} more than once`);
671
+ if (seenStatusMin.has(keyOf(rule.statusMin))) {
672
+ issue([...at, "statusMin"], `errors.rules lists statusMin ${rule.statusMin} more than once${sameAs}`);
543
673
  }
544
- seenStatusMin.add(rule.statusMin);
674
+ seenStatusMin.add(keyOf(rule.statusMin));
545
675
  }
546
676
  });
547
677
  const modelKeys = new Set(Object.keys(entry.models.catalog));
@@ -573,6 +703,22 @@ const decideCatalogJsonSchema = z
573
703
  issue(["decisionLimits", "models", id], "decisionLimits.models key must be a key of models.catalog");
574
704
  }
575
705
  }
706
+ const mediaKeys = decide.media;
707
+ const mediaAllowance = entry.decisionLimits?.media;
708
+ if (mediaKeys !== undefined && mediaAllowance === undefined) {
709
+ issue(["decide", "media"], "decide.media needs decisionLimits.media: without an allowance the base class refuses every image and video, so these keys would never be written");
710
+ }
711
+ if (mediaKeys === undefined && mediaAllowance !== undefined) {
712
+ issue(["decisionLimits", "media"], "decisionLimits.media needs decide.media: without body keys the engine would prepare a request's media and then leave it out of the body");
713
+ }
714
+ if (mediaKeys !== undefined && mediaAllowance !== undefined) {
715
+ if (mediaAllowance.video && mediaKeys.videoKey === undefined) {
716
+ issue(["decide", "media", "videoKey"], "decide.media.videoKey is required when decisionLimits.media.video is true");
717
+ }
718
+ if (!mediaAllowance.video && mediaKeys.videoKey !== undefined) {
719
+ issue(["decide", "media", "videoKey"], "decide.media.videoKey must be omitted when decisionLimits.media.video is false");
720
+ }
721
+ }
576
722
  if (decide.baseURLRequired === true &&
577
723
  !decide.endpoint.includes("{baseURL}")) {
578
724
  issue(["decide", "endpoint"], "decide.endpoint must contain {baseURL} when decide.baseURLRequired is true");
@@ -0,0 +1,86 @@
1
+ {
2
+ "$schema": "./provider-catalog.schema.json",
3
+ "id": "xor",
4
+ "displayName": "XOR",
5
+ "aliases": [],
6
+ "inferenceKinds": ["decide"],
7
+ "wire": {},
8
+ "models": {
9
+ "default": "xor-1.1",
10
+ "topModels": ["xor-1.1"],
11
+ "catalog": {
12
+ "xor-1.1": {
13
+ "contextWindow": 250000,
14
+ "maxOutputTokens": 256,
15
+ "vision": false,
16
+ "status": "production",
17
+ "description": "Recommended - XOR 1.1, decision model that also reads images and video"
18
+ }
19
+ }
20
+ },
21
+ "timeouts": { "decideMs": 5000 },
22
+ "decisionLimits": {
23
+ "maxStateTokens": 200000,
24
+ "nonAsciiTokensPerChar": 1,
25
+ "media": { "maxImages": 8, "video": true, "maxRequestBytes": 8388608 }
26
+ },
27
+ "decide": {
28
+ "dialect": "system-one",
29
+ "order": 3,
30
+ "baseURLRequired": true,
31
+ "baseURLNormalize": ["strip-trailing-slash", "strip-v1-suffix"],
32
+ "baseURLShape": "origin",
33
+ "endpoint": "{baseURL}/v1/systemone",
34
+ "model": { "placement": "body" },
35
+ "media": { "imagesKey": "images", "videoKey": "video" },
36
+ "requestIdHeaders": [
37
+ "x-request-id",
38
+ "x-typesafe-request-id",
39
+ "x-litellm-call-id"
40
+ ],
41
+ "errors": {
42
+ "rules": [
43
+ { "status": 401, "kind": "authentication" },
44
+ { "status": 413, "kind": "max_tokens_exceeded" },
45
+ { "status": 429, "kind": "rate_limit" },
46
+ { "status": 503, "kind": "overloaded" },
47
+ {
48
+ "statusMin": 500,
49
+ "messagePattern": "(context|prefill|prompt).{0,40}(length|limit|too long|exceed)|(too long|exceeds?).{0,40}(context|tokens)",
50
+ "kind": "max_tokens_exceeded"
51
+ },
52
+ { "statusMin": 500, "kind": "server" }
53
+ ],
54
+ "default": "invalid_request",
55
+ "bodyShapes": [
56
+ { "path": "error.message" },
57
+ { "path": "error" },
58
+ {
59
+ "path": "detail",
60
+ "isArray": true,
61
+ "formatter": "fastapi-validation"
62
+ },
63
+ { "path": "detail" }
64
+ ]
65
+ }
66
+ },
67
+ "setup": {
68
+ "url": "https://huggingface.co/juspay/xor",
69
+ "apiKeyFormat": null,
70
+ "billingPolicy": "free-tier",
71
+ "description": "API key for your XOR endpoint",
72
+ "instructions": [
73
+ "1. Reach an XOR deployment, or use a LiteLLM proxy with a pass-through route to one",
74
+ "2. Set XOR_BASE_URL to its origin (NeuroLink calls <XOR_BASE_URL>/v1/systemone)",
75
+ "3. Set {apiKeyEnvVar} to the key it accepts (on LiteLLM, a virtual key whose team allows xor-1.1)"
76
+ ]
77
+ },
78
+ "evidence": {
79
+ "rosterVerified": {
80
+ "date": "2026-09-29",
81
+ "method": "The single model id xor-1.1 is the roster the hand-written XOR descriptor shipped; 2026-09-29 is the date it was added, not a date it was checked. The roster was never verified against a live XOR endpoint, and no live probe was run in this change"
82
+ },
83
+ "liveMatrix": null,
84
+ "addedInPR": "https://github.com/juspay/neurolink/pull/1984"
85
+ }
86
+ }
@@ -1,4 +1,4 @@
1
- import type { DecideCatalogJson, DecisionDialect, DecisionError, DecisionState, UnknownRecord } from "../types/index.js";
1
+ import type { DecideCatalogJson, DecisionDialect, DecisionError, DecisionPreparedMedia, 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 {
@@ -15,7 +15,7 @@ export declare class ConfiguredDecisionProvider extends SystemOneDecisionProvide
15
15
  protected missingConfigMessage(): string | undefined;
16
16
  protected decisionEndpoint(): string;
17
17
  protected decisionHeaders(): Record<string, string>;
18
- protected buildDecisionBody(state: DecisionState, questions: Record<string, Record<string, unknown>>, model: string): Record<string, unknown>;
18
+ protected buildDecisionBody(state: DecisionState, questions: Record<string, Record<string, unknown>>, model: string, media?: DecisionPreparedMedia): Record<string, unknown>;
19
19
  protected parseDecisionError(status: number, payload: unknown, requestId: string | undefined): DecisionError;
20
20
  protected readRequestId(headers: Headers): string | undefined;
21
21
  protected resolveResponseModel(payload: Record<string, unknown>, requestedModel: string): string;
@@ -2,7 +2,7 @@ import { logger } from "../utils/logger.js";
2
2
  import { redactUrlForError } from "../utils/logSanitize.js";
3
3
  import { getProviderModel } from "../utils/providerConfig.js";
4
4
  import { catalogCredentialsKey, catalogEnvVar } from "./catalog/loader.js";
5
- import { normalizeBaseURL } from "./decide/baseUrl.js";
5
+ import { baseURLShapeProblem, normalizeBaseURL } from "./decide/baseUrl.js";
6
6
  import { getDecisionDialect } from "./decide/dialects/index.js";
7
7
  import { classifyDecisionError } from "./decide/errors.js";
8
8
  import { isRecord } from "./decide/guards.js";
@@ -54,8 +54,14 @@ export class ConfiguredDecisionProvider extends SystemOneDecisionProvider {
54
54
  return `${this.entry.displayName} requires an API key. Set ${catalogEnvVar(this.entry, "apiKey")} or pass credentials.${catalogCredentialsKey(this.entry)}.apiKey.`;
55
55
  }
56
56
  missingConfigMessage() {
57
- return this.entry.decide.baseURLRequired && !this.baseURL
58
- ? `${this.entry.displayName} requires a base URL. Set ${catalogEnvVar(this.entry, "baseURL")} or pass credentials.${catalogCredentialsKey(this.entry)}.baseURL.`
57
+ const { baseURLRequired, baseURLShape } = this.entry.decide;
58
+ if (!this.baseURL) {
59
+ return baseURLRequired
60
+ ? `${this.entry.displayName} requires a base URL. Set ${catalogEnvVar(this.entry, "baseURL")} or pass credentials.${catalogCredentialsKey(this.entry)}.baseURL.`
61
+ : undefined;
62
+ }
63
+ return baseURLShape
64
+ ? baseURLShapeProblem(baseURLShape, this.baseURL, this.entry.displayName, `Set ${catalogEnvVar(this.entry, "baseURL")} or pass credentials.${catalogCredentialsKey(this.entry)}.baseURL to the origin, and give the key through ${catalogEnvVar(this.entry, "apiKey")} or credentials.${catalogCredentialsKey(this.entry)}.apiKey.`)
59
65
  : undefined;
60
66
  }
61
67
  decisionEndpoint() {
@@ -67,11 +73,22 @@ export class ConfiguredDecisionProvider extends SystemOneDecisionProvider {
67
73
  "Content-Type": "application/json",
68
74
  };
69
75
  }
70
- buildDecisionBody(state, questions, model) {
76
+ buildDecisionBody(state, questions, model, media) {
71
77
  const { placement, alias } = this.entry.decide.model;
72
- return placement === "body-unless-alias" && model === alias
78
+ const body = placement === "body-unless-alias" && model === alias
73
79
  ? { state, questions }
74
80
  : { model, state, questions };
81
+ const keys = this.entry.decide.media;
82
+ if (!keys || !media) {
83
+ return body;
84
+ }
85
+ // A key is written only for media the request carries: a server may refuse
86
+ // an empty `images`.
87
+ return {
88
+ ...body,
89
+ ...(media.images.length > 0 ? { [keys.imagesKey]: media.images } : {}),
90
+ ...(media.video && keys.videoKey ? { [keys.videoKey]: media.video } : {}),
91
+ };
75
92
  }
76
93
  parseDecisionError(status, payload, requestId) {
77
94
  return classifyDecisionError(this.entry.decide.errors, {
@@ -1,2 +1,4 @@
1
- import type { DecideBaseURLTransform } from "../../types/index.js";
1
+ import type { DecideBaseURLShape, DecideBaseURLTransform } from "../../types/index.js";
2
2
  export declare function normalizeBaseURL(raw: string, transforms: readonly DecideBaseURLTransform[]): string;
3
+ /** Why a normalised base URL fails the shape, or undefined when it passes. */
4
+ export declare function baseURLShapeProblem(shape: DecideBaseURLShape, baseURL: string, displayName: string, fix: string): string | undefined;
@@ -8,7 +8,50 @@ const TRANSFORMS = {
8
8
  }
9
9
  return value.slice(0, end);
10
10
  },
11
+ // A fixed-width tail check, not a pattern: nothing here can backtrack.
12
+ "strip-v1-suffix": (value) => {
13
+ const start = value.length - 3;
14
+ return start >= 0 &&
15
+ value[start] === "/" &&
16
+ (value[start + 1] === "v" || value[start + 1] === "V") &&
17
+ value[start + 2] === "1"
18
+ ? value.slice(0, start)
19
+ : value;
20
+ },
11
21
  };
12
22
  export function normalizeBaseURL(raw, transforms) {
13
23
  return transforms.reduce((value, name) => TRANSFORMS[name](value), raw);
14
24
  }
25
+ const SHAPE_CHECKS = {
26
+ // None of these messages repeats the base URL: it can hold a credential,
27
+ // `fetch` rejects userinfo and echoes it, and a route appended after a query
28
+ // string or fragment ends up inside it.
29
+ origin: (baseURL, displayName, fix) => {
30
+ try {
31
+ const url = new URL(baseURL);
32
+ // `new URL()` accepts any scheme, so a host:port with no scheme parses as
33
+ // one (`xor.internal:8080` has the scheme `xor.internal:`) and would reach
34
+ // `fetch` as a network error instead of this message.
35
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
36
+ return `The ${displayName} base URL must start with https:// or http://. ${fix}`;
37
+ }
38
+ // The raw text is checked as well: `search` and `hash` are empty for a
39
+ // lone "?" or "#", which still swallows the route appended after it.
40
+ return url.username ||
41
+ url.password ||
42
+ url.search ||
43
+ url.hash ||
44
+ baseURL.includes("?") ||
45
+ baseURL.includes("#")
46
+ ? `The ${displayName} base URL must not carry credentials, a query string or a fragment. ${fix}`
47
+ : undefined;
48
+ }
49
+ catch {
50
+ return `The ${displayName} base URL is not a valid absolute URL. ${fix}`;
51
+ }
52
+ },
53
+ };
54
+ /** Why a normalised base URL fails the shape, or undefined when it passes. */
55
+ export function baseURLShapeProblem(shape, baseURL, displayName, fix) {
56
+ return SHAPE_CHECKS[shape](baseURL, displayName, fix);
57
+ }
@@ -7,14 +7,29 @@ const RETRYABLE_BY_DEFAULT = new Set([
7
7
  "server",
8
8
  ]);
9
9
  const isRetryableByDefault = (kind) => RETRYABLE_BY_DEFAULT.has(kind);
10
- function ruleMatches(rule, status) {
10
+ // The window a messagePattern is searched in: an error body can be arbitrarily
11
+ // long, and a pattern only needs the start of it.
12
+ const MESSAGE_MATCH_LIMIT = 2000;
13
+ const COMPILED_PATTERNS = new Map();
14
+ function compilePattern(source) {
15
+ const cached = COMPILED_PATTERNS.get(source);
16
+ if (cached) {
17
+ return cached;
18
+ }
19
+ const compiled = new RegExp(source, "i");
20
+ COMPILED_PATTERNS.set(source, compiled);
21
+ return compiled;
22
+ }
23
+ function ruleMatches(rule, status, message) {
11
24
  const statuses = rule.status === undefined
12
25
  ? []
13
26
  : typeof rule.status === "number"
14
27
  ? [rule.status]
15
28
  : rule.status;
16
- return (statuses.includes(status) ||
17
- (rule.statusMin !== undefined && status >= rule.statusMin));
29
+ return ((statuses.includes(status) ||
30
+ (rule.statusMin !== undefined && status >= rule.statusMin)) &&
31
+ (rule.messagePattern === undefined ||
32
+ compilePattern(rule.messagePattern).test(message.slice(0, MESSAGE_MATCH_LIMIT))));
18
33
  }
19
34
  function messageFromShape(shape, value) {
20
35
  if (shape.isArray) {
@@ -27,31 +42,42 @@ function messageFromShape(shape, value) {
27
42
  }
28
43
  return shape.formatter ? DECIDE_FORMATTERS[shape.formatter](value) : value;
29
44
  }
45
+ function firstShapeText(shapes, payload) {
46
+ for (const shape of shapes) {
47
+ const text = messageFromShape(shape, readPath(payload, shape.path));
48
+ if (text !== undefined) {
49
+ return { shape, text };
50
+ }
51
+ }
52
+ return undefined;
53
+ }
30
54
  export function classifyDecisionError(spec, input) {
31
55
  const { status, payload, requestId, label, apiKey } = input;
32
- const rule = spec.rules.find((candidate) => ruleMatches(candidate, status));
33
- const ruleKind = rule?.kind ?? spec.default;
34
- const ruleRetryable = rule?.retryable ?? isRetryableByDefault(ruleKind);
35
56
  // Redacted once, here: it stands in for a shape's text that redaction
36
57
  // empties, and the label is catalog data a key can match. Redaction is not
37
58
  // idempotent for a key such as "e" (it matches inside "[redacted]"), so the
38
59
  // fallback is never passed through it a second time.
39
60
  const fallback = redactCredentials(`${label} request failed with HTTP ${status}`, apiKey);
40
- const build = (kind, retryable, text) => ({
61
+ const found = firstShapeText(spec.bodyShapes, payload);
62
+ // The message comes first because a rule may match on it: the key is gone
63
+ // from the text a pattern sees.
64
+ const message = (found?.text && redactCredentials(found.text, apiKey)) || fallback;
65
+ const rule = spec.rules.find((candidate) => ruleMatches(candidate, status, message));
66
+ const ruleKind = rule?.kind ?? spec.default;
67
+ const ruleRetryable = rule?.retryable ?? isRetryableByDefault(ruleKind);
68
+ const build = (kind, retryable) => ({
41
69
  kind,
42
- message: (text && redactCredentials(text, apiKey)) || fallback,
70
+ message,
43
71
  status,
44
72
  requestId,
45
73
  retryable,
46
74
  });
47
- for (const shape of spec.bodyShapes) {
48
- const text = messageFromShape(shape, readPath(payload, shape.path));
49
- if (text !== undefined) {
50
- return build(shape.kind ?? ruleKind, shape.retryable ??
51
- (shape.kind === undefined
52
- ? ruleRetryable
53
- : isRetryableByDefault(shape.kind)), text);
54
- }
75
+ if (found) {
76
+ const { shape } = found;
77
+ return build(shape.kind ?? ruleKind, shape.retryable ??
78
+ (shape.kind === undefined
79
+ ? ruleRetryable
80
+ : isRetryableByDefault(shape.kind)));
55
81
  }
56
82
  return build(ruleKind, ruleRetryable);
57
83
  }
@@ -103,3 +103,4 @@ export * from "./dispatch.js";
103
103
  export * from "./proxyRestart.js";
104
104
  export * from "./proxyBudget.js";
105
105
  export * from "./decision.js";
106
+ export * from "./ollamaDiagnostics.js";
@@ -119,3 +119,4 @@ export * from "./proxyRestart.js";
119
119
  export * from "./proxyBudget.js";
120
120
  // `decide` inference-type contracts (TypeSafe Jev is the first provider)
121
121
  export * from "./decision.js";
122
+ export * from "./ollamaDiagnostics.js";
@@ -0,0 +1,31 @@
1
+ /** Native Ollama diagnostic identity, outcome and captured policy state. */
2
+ export type OllamaDiagnosticContext = Readonly<{
3
+ /** Private resolved URL: never include it, its key, or credentials in logs. */
4
+ url: string;
5
+ key: string;
6
+ model: string;
7
+ }>;
8
+ export type OllamaDiagnosticSnapshot = Readonly<{
9
+ count: number;
10
+ lastFailureAt: number | undefined;
11
+ revision: number;
12
+ generation: number;
13
+ }>;
14
+ export type OllamaDiagnosticState = {
15
+ count: number;
16
+ lastFailureAt: number | undefined;
17
+ revision: number;
18
+ generation: number;
19
+ };
20
+ export type OllamaDiagnosticPermit = Readonly<{
21
+ kind: "allowed";
22
+ context: OllamaDiagnosticContext;
23
+ generation: number;
24
+ }>;
25
+ export type OllamaDiagnosticGate = OllamaDiagnosticPermit | Readonly<{
26
+ kind: "suppressed";
27
+ count: number;
28
+ reason: string;
29
+ generation: number;
30
+ }>;
31
+ export type OllamaDiagnosticOutcome = "success" | "transport-failure" | "protocol-failure" | "not-run";
@@ -0,0 +1 @@
1
+ export {};