visual-ai-assertions 0.20.0 → 0.22.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.
package/dist/index.d.cts CHANGED
@@ -32,6 +32,7 @@ declare const Provider: {
32
32
  /** Known model names grouped by provider. */
33
33
  declare const Model: {
34
34
  readonly Anthropic: {
35
+ readonly FABLE_5_1: "claude-fable-5-1";
35
36
  readonly FABLE_5: "claude-fable-5";
36
37
  readonly OPUS_5: "claude-opus-5";
37
38
  readonly OPUS_4_8: "claude-opus-4-8";
@@ -42,6 +43,7 @@ declare const Model: {
42
43
  readonly HAIKU_4_5: "claude-haiku-4-5";
43
44
  };
44
45
  readonly OpenAI: {
46
+ readonly GPT_6_ASTRA: "gpt-6-astra";
45
47
  readonly GPT_5_6_SOL: "gpt-5.6-sol";
46
48
  readonly GPT_5_6_TERRA: "gpt-5.6-terra";
47
49
  readonly GPT_5_6_LUNA: "gpt-5.6-luna";
@@ -84,7 +86,7 @@ type KnownModelName = (typeof Model.Anthropic)[keyof typeof Model.Anthropic] | (
84
86
  /** Default model selection used when a caller omits `config.model`. */
85
87
  declare const DEFAULT_MODELS: {
86
88
  readonly anthropic: "claude-sonnet-4-6";
87
- readonly openai: "gpt-5.4-mini";
89
+ readonly openai: "gpt-5.6-luna";
88
90
  readonly google: "gemini-3-flash-preview";
89
91
  readonly openrouter: "qwen/qwen3.6-flash";
90
92
  };
@@ -667,6 +669,17 @@ interface VisualAIConfig {
667
669
  * Anthropic (Claude auto-downscales images).
668
670
  */
669
671
  imageDetail?: ImageDetailLevel;
672
+ /**
673
+ * Per-request timeout in milliseconds, forwarded to the provider SDK.
674
+ * Omitted by default, so each SDK's own default applies (OpenAI and
675
+ * OpenRouter 10 minutes, Google 1 minute, Anthropic per its own rules).
676
+ *
677
+ * Worth setting for heavy reasoning models: they can spend many minutes on a
678
+ * single call, and the SDK default lets a request hang far longer than most
679
+ * test suites should tolerate. Note that provider SDKs retry timed-out
680
+ * requests, so total wall time can exceed this value by a multiple.
681
+ */
682
+ timeout?: number;
670
683
  trackUsage?: boolean;
671
684
  }
672
685
  /** Optional instructions for `check()`. */
@@ -703,6 +716,37 @@ interface CompareOptions {
703
716
  /** Optional instructions for `elementsVisible()` and `elementsHidden()`. */
704
717
  interface ElementsVisibilityOptions {
705
718
  instructions?: readonly string[];
719
+ /**
720
+ * Whether the screenshot shows the interface in its finished state. Defaults
721
+ * to `true`, which is what a test asserting on a settled screen wants: an
722
+ * element under a loading spinner, skeleton or error overlay is reported as
723
+ * not properly visible, because the overlay says the content is not ready.
724
+ *
725
+ * Set to `false` when the screenshot was deliberately captured mid-load, so
726
+ * loading chrome is expected rather than a defect. The finished-state rules
727
+ * are then left out of the prompt entirely, and only presence and clipping
728
+ * are judged. Note this omits the guidance rather than inverting it — to have
729
+ * the model actively disregard loading indicators, say so in `instructions`.
730
+ */
731
+ finalState?: boolean;
732
+ /**
733
+ * Whether an element that is present but clearly badly rendered should fail.
734
+ * Defaults to `false`: `elementsVisible()` is a presence check, and an element
735
+ * counts as visible if it is there at all, whatever it looks like.
736
+ *
737
+ * Set to `true` to judge presentation as well. Text at contrast too low to
738
+ * read, elements overlapping, an element out of alignment with its siblings,
739
+ * or text cut off mid-word then fail, with the model told to say the element
740
+ * is present before naming the defect. Opt in per assertion where a rendering
741
+ * defect is the thing being checked: measured with the rule on and off, it
742
+ * caught exactly the bullet naming a present-but-overlapping element and
743
+ * nothing else, while making the better model noticeably flakier on plain
744
+ * presence questions.
745
+ *
746
+ * Ignored by `elementsHidden()`, where the question is absence and a rendering
747
+ * defect cannot change the answer.
748
+ */
749
+ requireCorrectRendering?: boolean;
706
750
  }
707
751
  /** Options for the built-in accessibility template. */
708
752
  interface AccessibilityOptions {
package/dist/index.d.ts CHANGED
@@ -32,6 +32,7 @@ declare const Provider: {
32
32
  /** Known model names grouped by provider. */
33
33
  declare const Model: {
34
34
  readonly Anthropic: {
35
+ readonly FABLE_5_1: "claude-fable-5-1";
35
36
  readonly FABLE_5: "claude-fable-5";
36
37
  readonly OPUS_5: "claude-opus-5";
37
38
  readonly OPUS_4_8: "claude-opus-4-8";
@@ -42,6 +43,7 @@ declare const Model: {
42
43
  readonly HAIKU_4_5: "claude-haiku-4-5";
43
44
  };
44
45
  readonly OpenAI: {
46
+ readonly GPT_6_ASTRA: "gpt-6-astra";
45
47
  readonly GPT_5_6_SOL: "gpt-5.6-sol";
46
48
  readonly GPT_5_6_TERRA: "gpt-5.6-terra";
47
49
  readonly GPT_5_6_LUNA: "gpt-5.6-luna";
@@ -84,7 +86,7 @@ type KnownModelName = (typeof Model.Anthropic)[keyof typeof Model.Anthropic] | (
84
86
  /** Default model selection used when a caller omits `config.model`. */
85
87
  declare const DEFAULT_MODELS: {
86
88
  readonly anthropic: "claude-sonnet-4-6";
87
- readonly openai: "gpt-5.4-mini";
89
+ readonly openai: "gpt-5.6-luna";
88
90
  readonly google: "gemini-3-flash-preview";
89
91
  readonly openrouter: "qwen/qwen3.6-flash";
90
92
  };
@@ -667,6 +669,17 @@ interface VisualAIConfig {
667
669
  * Anthropic (Claude auto-downscales images).
668
670
  */
669
671
  imageDetail?: ImageDetailLevel;
672
+ /**
673
+ * Per-request timeout in milliseconds, forwarded to the provider SDK.
674
+ * Omitted by default, so each SDK's own default applies (OpenAI and
675
+ * OpenRouter 10 minutes, Google 1 minute, Anthropic per its own rules).
676
+ *
677
+ * Worth setting for heavy reasoning models: they can spend many minutes on a
678
+ * single call, and the SDK default lets a request hang far longer than most
679
+ * test suites should tolerate. Note that provider SDKs retry timed-out
680
+ * requests, so total wall time can exceed this value by a multiple.
681
+ */
682
+ timeout?: number;
670
683
  trackUsage?: boolean;
671
684
  }
672
685
  /** Optional instructions for `check()`. */
@@ -703,6 +716,37 @@ interface CompareOptions {
703
716
  /** Optional instructions for `elementsVisible()` and `elementsHidden()`. */
704
717
  interface ElementsVisibilityOptions {
705
718
  instructions?: readonly string[];
719
+ /**
720
+ * Whether the screenshot shows the interface in its finished state. Defaults
721
+ * to `true`, which is what a test asserting on a settled screen wants: an
722
+ * element under a loading spinner, skeleton or error overlay is reported as
723
+ * not properly visible, because the overlay says the content is not ready.
724
+ *
725
+ * Set to `false` when the screenshot was deliberately captured mid-load, so
726
+ * loading chrome is expected rather than a defect. The finished-state rules
727
+ * are then left out of the prompt entirely, and only presence and clipping
728
+ * are judged. Note this omits the guidance rather than inverting it — to have
729
+ * the model actively disregard loading indicators, say so in `instructions`.
730
+ */
731
+ finalState?: boolean;
732
+ /**
733
+ * Whether an element that is present but clearly badly rendered should fail.
734
+ * Defaults to `false`: `elementsVisible()` is a presence check, and an element
735
+ * counts as visible if it is there at all, whatever it looks like.
736
+ *
737
+ * Set to `true` to judge presentation as well. Text at contrast too low to
738
+ * read, elements overlapping, an element out of alignment with its siblings,
739
+ * or text cut off mid-word then fail, with the model told to say the element
740
+ * is present before naming the defect. Opt in per assertion where a rendering
741
+ * defect is the thing being checked: measured with the rule on and off, it
742
+ * caught exactly the bullet naming a present-but-overlapping element and
743
+ * nothing else, while making the better model noticeably flakier on plain
744
+ * presence questions.
745
+ *
746
+ * Ignored by `elementsHidden()`, where the question is absence and a rendering
747
+ * defect cannot change the answer.
748
+ */
749
+ requireCorrectRendering?: boolean;
706
750
  }
707
751
  /** Options for the built-in accessibility template. */
708
752
  interface AccessibilityOptions {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ var Provider = {
20
20
  };
21
21
  var Model = {
22
22
  Anthropic: {
23
+ FABLE_5_1: "claude-fable-5-1",
23
24
  FABLE_5: "claude-fable-5",
24
25
  OPUS_5: "claude-opus-5",
25
26
  OPUS_4_8: "claude-opus-4-8",
@@ -30,6 +31,7 @@ var Model = {
30
31
  HAIKU_4_5: "claude-haiku-4-5"
31
32
  },
32
33
  OpenAI: {
34
+ GPT_6_ASTRA: "gpt-6-astra",
33
35
  GPT_5_6_SOL: "gpt-5.6-sol",
34
36
  GPT_5_6_TERRA: "gpt-5.6-terra",
35
37
  GPT_5_6_LUNA: "gpt-5.6-luna",
@@ -69,12 +71,16 @@ var Model = {
69
71
  };
70
72
  var DEFAULT_MODELS = {
71
73
  [Provider.ANTHROPIC]: Model.Anthropic.SONNET_4_6,
72
- [Provider.OPENAI]: Model.OpenAI.GPT_5_4_MINI,
74
+ [Provider.OPENAI]: Model.OpenAI.GPT_5_6_LUNA,
73
75
  [Provider.GOOGLE]: Model.Google.GEMINI_3_FLASH_PREVIEW,
74
76
  [Provider.OPENROUTER]: Model.OpenRouter.QWEN_3_6_FLASH
75
77
  };
76
78
  var DEFAULT_MAX_TOKENS = 4096;
77
79
  var OPENAI_REASONING_MAX_TOKENS = 16384;
80
+ var OPENAI_HEAVY_REASONING_MAX_TOKENS = 32768;
81
+ var MODELS_REQUIRING_LARGE_OUTPUT_BUDGET = /* @__PURE__ */ new Set([
82
+ Model.OpenAI.GPT_6_ASTRA
83
+ ]);
78
84
  var MODEL_TO_PROVIDER = new Map([
79
85
  ...Object.values(Model.Anthropic).map((m) => [m, Provider.ANTHROPIC]),
80
86
  ...Object.values(Model.OpenAI).map((m) => [m, Provider.OPENAI]),
@@ -419,22 +425,50 @@ function buildComparePrompt(options) {
419
425
  }
420
426
 
421
427
  // src/templates/elements-visibility.ts
422
- var ELEMENTS_VISIBLE_ROLE = "Check whether specific UI elements are present and fully visible in this screenshot.";
423
428
  var ELEMENTS_HIDDEN_ROLE = "Check whether specific UI elements are absent or hidden in this screenshot.";
424
- var ELEMENTS_VISIBLE_EDGE_RULES = [
425
- "If an element is partially visible (cut off by screenshot boundary), it is NOT considered fully visible \u2014 the check for that element should fail. Note the partial visibility in your reasoning."
429
+ function joinClauses(clauses) {
430
+ if (clauses.length <= 1) return clauses[0] ?? "";
431
+ if (clauses.length === 2) return `${clauses[0]} and ${clauses[1]}`;
432
+ return `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
433
+ }
434
+ function visibleRole(finalState, requireCorrectRendering) {
435
+ const clauses = ["present", "properly visible"];
436
+ if (requireCorrectRendering) clauses.push("correctly rendered");
437
+ if (finalState) clauses.push("in their finished state");
438
+ return `Check whether specific UI elements are ${joinClauses(clauses)} in this screenshot.`;
439
+ }
440
+ var ELEMENTS_VISIBLE_CLIPPING_RULES = [
441
+ "When an element is partly rendered but cut off at an edge, decide whether ordinary scrolling would bring it fully into view. For example, a card peeking past the end of a horizontal carousel, a filter chip in a row that continues past the screen edge, or a list item partly below the bottom of a scrolling feed is reachable that way, so the check for that element PASSES. Say in your reasoning that it is reached by scrolling.",
442
+ "An element that scrolling cannot bring into view is NOT properly visible: one sliced by the screen edge itself, or cut off or overlapped by fixed chrome such as the status bar, a notch, a home indicator, a sticky header, or a fixed bottom navigation bar. That is a layout fault, so the check for that element FAILS. Describe the clipping in your reasoning.",
443
+ "An element you cannot see at all is not visible, even if the page might reveal it after scrolling. Judge only what this screenshot actually shows."
426
444
  ];
427
- var ELEMENTS_HIDDEN_EDGE_RULES = [
428
- "If an element is partially visible (cut off by screenshot boundary), it is NOT considered hidden \u2014 the check for that element should fail. Note the partial visibility in your reasoning."
445
+ var ELEMENTS_VISIBLE_FINAL_STATE_RULE = "Judge each element in its finished, presented state. Things a design draws on top of an element \u2014 a badge, a favourite icon, a duration or price pill, a gradient scrim \u2014 coexist with finished content and leave it visible. An overlay that says the element is NOT ready \u2014 a loading spinner, a skeleton placeholder, a shimmer, a progress bar, an error or retry overlay \u2014 means the element is not properly visible even when you can still make out what sits underneath, so the check for that element FAILS. Name which of the two you are seeing in your reasoning.";
446
+ var ELEMENTS_VISIBLE_CORRECT_RENDERING_RULE = "An element that is present but clearly defective in how it is rendered is NOT properly visible: text at contrast too low to read, elements overlapping or colliding with one another, an element visibly out of alignment with the siblings it should line up with, or text cut off mid-word inside its own container. The check for that element FAILS. In your reasoning, say that the element is present and then name the defect. Only clear, unambiguous defects count: do not fail an element for tight spacing, stylistic choices, or anything you would have to argue for.";
447
+ var ELEMENTS_VISIBLE_OCCLUSION_RULE = "An element a user could not read or use because a modal, dialog, cookie banner, toast or similar overlay covers it is NOT visible: the check for that element FAILS.";
448
+ function visibleRules(finalState, requireCorrectRendering) {
449
+ return [
450
+ ...ELEMENTS_VISIBLE_CLIPPING_RULES,
451
+ ...finalState ? [ELEMENTS_VISIBLE_FINAL_STATE_RULE] : [],
452
+ ...requireCorrectRendering ? [ELEMENTS_VISIBLE_CORRECT_RENDERING_RULE] : [],
453
+ ELEMENTS_VISIBLE_OCCLUSION_RULE
454
+ ];
455
+ }
456
+ var ELEMENTS_HIDDEN_BASE_RULES = [
457
+ "An element that is rendered at all, even partly, is not hidden, so the check for that element FAILS. This includes one peeking past the edge of a scrollable row or feed, which the user reaches by scrolling normally. Note the partial visibility in your reasoning.",
458
+ "An element that appears nowhere in this screenshot counts as hidden, even if the page might reveal it after scrolling. Judge only what this screenshot actually shows."
429
459
  ];
460
+ var ELEMENTS_HIDDEN_FINAL_STATE_RULE = "An element sitting under a loading spinner, skeleton, progress bar or error overlay is still rendered, so it is not hidden and the check for that element FAILS. It is not properly visible either; that is what the visible check is for.";
461
+ function hiddenRules(finalState) {
462
+ return finalState ? [...ELEMENTS_HIDDEN_BASE_RULES, ELEMENTS_HIDDEN_FINAL_STATE_RULE] : ELEMENTS_HIDDEN_BASE_RULES;
463
+ }
430
464
  function buildElementsVisibilityPrompt(elements, visible, options) {
431
- const statements = visible ? elements.map((el) => `The element "${el}" is fully visible on the page`) : elements.map((el) => `The element "${el}" is NOT visible on the page`);
432
- const defaultRules = visible ? ELEMENTS_VISIBLE_EDGE_RULES : ELEMENTS_HIDDEN_EDGE_RULES;
465
+ const statements = visible ? elements.map((el) => `The element "${el}" is visible on the page`) : elements.map((el) => `The element "${el}" is NOT visible on the page`);
466
+ const finalState = options?.finalState ?? true;
467
+ const correctRendering = visible && (options?.requireCorrectRendering ?? false);
468
+ const defaultRules = visible ? visibleRules(finalState, correctRendering) : hiddenRules(finalState);
433
469
  const instructions = options?.instructions ? [...defaultRules, ...options.instructions] : defaultRules;
434
- return buildCheckPrompt(statements, {
435
- role: visible ? ELEMENTS_VISIBLE_ROLE : ELEMENTS_HIDDEN_ROLE,
436
- instructions
437
- });
470
+ const role = visible ? visibleRole(finalState, correctRendering) : ELEMENTS_HIDDEN_ROLE;
471
+ return buildCheckPrompt(statements, { role, instructions });
438
472
  }
439
473
 
440
474
  // src/templates/accessibility.ts
@@ -544,6 +578,7 @@ function parseRetryAfter(value) {
544
578
 
545
579
  // src/providers/anthropic.ts
546
580
  var XHIGH_CAPABLE_MODELS = /* @__PURE__ */ new Set([
581
+ Model.Anthropic.FABLE_5_1,
547
582
  Model.Anthropic.FABLE_5,
548
583
  Model.Anthropic.OPUS_5,
549
584
  Model.Anthropic.OPUS_4_8,
@@ -567,12 +602,14 @@ var AnthropicDriver = class {
567
602
  maxTokens;
568
603
  apiKeyOrEnv;
569
604
  reasoningEffort;
605
+ timeout;
570
606
  constructor(config) {
571
607
  this.model = config.model;
572
608
  this.maxTokens = config.maxTokens;
573
609
  this.client = null;
574
610
  this.apiKeyOrEnv = config.apiKey;
575
611
  this.reasoningEffort = config.reasoningEffort;
612
+ this.timeout = config.timeout;
576
613
  }
577
614
  async getClient() {
578
615
  if (this.client) return this.client;
@@ -591,7 +628,10 @@ var AnthropicDriver = class {
591
628
  "Anthropic API key not found. Set ANTHROPIC_API_KEY or pass apiKey in config."
592
629
  );
593
630
  }
594
- this.client = new Anthropic({ apiKey });
631
+ this.client = new Anthropic({
632
+ apiKey,
633
+ ...this.timeout !== void 0 && { timeout: this.timeout }
634
+ });
595
635
  return this.client;
596
636
  }
597
637
  async sendMessage(images, prompt, _options) {
@@ -689,6 +729,7 @@ var GoogleDriver = class {
689
729
  apiKeyOrEnv;
690
730
  reasoningEffort;
691
731
  imageDetail;
732
+ timeout;
692
733
  constructor(config) {
693
734
  this.model = config.model;
694
735
  this.maxTokens = config.maxTokens;
@@ -696,6 +737,7 @@ var GoogleDriver = class {
696
737
  this.apiKeyOrEnv = config.apiKey;
697
738
  this.reasoningEffort = config.reasoningEffort;
698
739
  this.imageDetail = config.imageDetail;
740
+ this.timeout = config.timeout;
699
741
  }
700
742
  toGeminiParts(images) {
701
743
  return images.map((img) => ({
@@ -719,7 +761,10 @@ var GoogleDriver = class {
719
761
  "Google API key not found. Set GOOGLE_API_KEY or pass apiKey in config."
720
762
  );
721
763
  }
722
- this.client = new GoogleGenAI({ apiKey });
764
+ this.client = new GoogleGenAI({
765
+ apiKey,
766
+ ...this.timeout !== void 0 && { httpOptions: { timeout: this.timeout } }
767
+ });
723
768
  return this.client;
724
769
  }
725
770
  async sendMessage(images, prompt, _options) {
@@ -805,6 +850,7 @@ var OpenAIDriver = class {
805
850
  apiKeyOrEnv;
806
851
  reasoningEffort;
807
852
  imageDetail;
853
+ timeout;
808
854
  constructor(config) {
809
855
  this.model = config.model;
810
856
  this.maxTokens = config.maxTokens;
@@ -812,6 +858,7 @@ var OpenAIDriver = class {
812
858
  this.apiKeyOrEnv = config.apiKey;
813
859
  this.reasoningEffort = config.reasoningEffort;
814
860
  this.imageDetail = config.imageDetail;
861
+ this.timeout = config.timeout;
815
862
  }
816
863
  async getClient() {
817
864
  if (this.client) return this.client;
@@ -828,7 +875,10 @@ var OpenAIDriver = class {
828
875
  "OpenAI API key not found. Set OPENAI_API_KEY or pass apiKey in config."
829
876
  );
830
877
  }
831
- this.client = new OpenAI({ apiKey });
878
+ this.client = new OpenAI({
879
+ apiKey,
880
+ ...this.timeout !== void 0 && { timeout: this.timeout }
881
+ });
832
882
  return this.client;
833
883
  }
834
884
  async sendMessage(images, prompt, options) {
@@ -903,6 +953,7 @@ var OpenRouterDriver = class {
903
953
  apiKeyOrEnv;
904
954
  reasoningEffort;
905
955
  imageDetail;
956
+ timeout;
906
957
  constructor(config) {
907
958
  this.model = config.model;
908
959
  this.maxTokens = config.maxTokens;
@@ -910,6 +961,7 @@ var OpenRouterDriver = class {
910
961
  this.apiKeyOrEnv = config.apiKey;
911
962
  this.reasoningEffort = config.reasoningEffort;
912
963
  this.imageDetail = config.imageDetail;
964
+ this.timeout = config.timeout;
913
965
  }
914
966
  async getClient() {
915
967
  if (this.client) return this.client;
@@ -928,7 +980,11 @@ var OpenRouterDriver = class {
928
980
  "OpenRouter API key not found. Set OPENROUTER_API_KEY or pass apiKey in config."
929
981
  );
930
982
  }
931
- this.client = new OpenAI({ apiKey, baseURL: OPENROUTER_BASE_URL });
983
+ this.client = new OpenAI({
984
+ apiKey,
985
+ baseURL: OPENROUTER_BASE_URL,
986
+ ...this.timeout !== void 0 && { timeout: this.timeout }
987
+ });
932
988
  return this.client;
933
989
  }
934
990
  async sendMessage(images, prompt, options) {
@@ -1058,13 +1114,21 @@ function resolveConfig(config) {
1058
1114
  `
1059
1115
  );
1060
1116
  }
1117
+ if (config.timeout !== void 0 && (!Number.isFinite(config.timeout) || config.timeout <= 0)) {
1118
+ throw new VisualAIConfigError(
1119
+ `Invalid timeout: ${config.timeout}. Must be a positive number of milliseconds.`
1120
+ );
1121
+ }
1061
1122
  const userSetMaxTokens = config.maxTokens !== void 0;
1062
1123
  let maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
1063
- if (!userSetMaxTokens && (provider === "openai" || provider === "openrouter") && (config.reasoningEffort === "high" || config.reasoningEffort === "xhigh")) {
1064
- maxTokens = OPENAI_REASONING_MAX_TOKENS;
1124
+ const effortNeedsLargeBudget = config.reasoningEffort === "high" || config.reasoningEffort === "xhigh";
1125
+ const modelNeedsLargeBudget = MODELS_REQUIRING_LARGE_OUTPUT_BUDGET.has(model);
1126
+ if (!userSetMaxTokens && (provider === "openai" || provider === "openrouter") && (effortNeedsLargeBudget || modelNeedsLargeBudget)) {
1127
+ maxTokens = modelNeedsLargeBudget ? OPENAI_HEAVY_REASONING_MAX_TOKENS : OPENAI_REASONING_MAX_TOKENS;
1065
1128
  if (debug) {
1129
+ const reason = modelNeedsLargeBudget ? `model "${model}", which exhausts smaller budgets on reasoning at any effort` : `provider "${provider}" with reasoningEffort "${config.reasoningEffort}"`;
1066
1130
  process.stderr.write(
1067
- `[visual-ai-assertions] Auto-increased maxTokens from ${DEFAULT_MAX_TOKENS} to ${OPENAI_REASONING_MAX_TOKENS} for provider "${provider}" with reasoningEffort "${config.reasoningEffort}".
1131
+ `[visual-ai-assertions] Auto-increased maxTokens from ${DEFAULT_MAX_TOKENS} to ${maxTokens} for ${reason}.
1068
1132
  `
1069
1133
  );
1070
1134
  }
@@ -1077,6 +1141,7 @@ function resolveConfig(config) {
1077
1141
  reasoningEffort: config.reasoningEffort,
1078
1142
  maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION,
1079
1143
  imageDetail: config.imageDetail ?? DEFAULT_IMAGE_DETAIL,
1144
+ timeout: config.timeout,
1080
1145
  debug,
1081
1146
  debugPrompt,
1082
1147
  debugResponse,
@@ -1087,6 +1152,11 @@ function resolveConfig(config) {
1087
1152
  // src/core/pricing.ts
1088
1153
  var PER_MILLION = 1e6;
1089
1154
  var PRICING_TABLE = {
1155
+ // Fable 5.1 is priced identically to Fable 5, the tier it succeeds.
1156
+ [`${Provider.ANTHROPIC}:${Model.Anthropic.FABLE_5_1}`]: {
1157
+ inputPricePerToken: 10 / PER_MILLION,
1158
+ outputPricePerToken: 50 / PER_MILLION
1159
+ },
1090
1160
  [`${Provider.ANTHROPIC}:${Model.Anthropic.FABLE_5}`]: {
1091
1161
  inputPricePerToken: 10 / PER_MILLION,
1092
1162
  outputPricePerToken: 50 / PER_MILLION
@@ -1119,6 +1189,12 @@ var PRICING_TABLE = {
1119
1189
  inputPricePerToken: 1 / PER_MILLION,
1120
1190
  outputPricePerToken: 5 / PER_MILLION
1121
1191
  },
1192
+ // Cached input is $1/MTok and cache writes $12.50/MTok; neither is modelled
1193
+ // here, since `calculateCost` applies no cache discount on any provider.
1194
+ [`${Provider.OPENAI}:${Model.OpenAI.GPT_6_ASTRA}`]: {
1195
+ inputPricePerToken: 10 / PER_MILLION,
1196
+ outputPricePerToken: 50 / PER_MILLION
1197
+ },
1122
1198
  [`${Provider.OPENAI}:${Model.OpenAI.GPT_5_6_SOL}`]: {
1123
1199
  inputPricePerToken: 5 / PER_MILLION,
1124
1200
  outputPricePerToken: 30 / PER_MILLION
@@ -2127,10 +2203,34 @@ function stripCodeFences(text) {
2127
2203
  var CheckResponseSchema = CheckResultSchema.omit({ usage: true });
2128
2204
  var AskResponseSchema = AskResultSchema.omit({ usage: true });
2129
2205
  var CompareResponseSchema = CompareResultSchema.omit({ usage: true });
2206
+ var STRAY_CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
2207
+ function parseJson(text) {
2208
+ try {
2209
+ return JSON.parse(text);
2210
+ } catch (first) {
2211
+ try {
2212
+ return JSON.parse(text.replace(/[\u0000-\u001f]/g, " "));
2213
+ } catch {
2214
+ throw first;
2215
+ }
2216
+ }
2217
+ }
2218
+ function stripControlCharacters(value) {
2219
+ if (typeof value === "string") return value.replace(STRAY_CONTROL_CHARS, "");
2220
+ if (Array.isArray(value)) return value.map((item) => stripControlCharacters(item));
2221
+ if (value !== null && typeof value === "object") {
2222
+ const out = {};
2223
+ for (const [key, item] of Object.entries(value)) {
2224
+ out[key] = stripControlCharacters(item);
2225
+ }
2226
+ return out;
2227
+ }
2228
+ return value;
2229
+ }
2130
2230
  function parseResponse(raw, schema) {
2131
2231
  let parsed;
2132
2232
  try {
2133
- parsed = JSON.parse(stripCodeFences(raw));
2233
+ parsed = stripControlCharacters(parseJson(stripCodeFences(raw)));
2134
2234
  } catch {
2135
2235
  throw new VisualAIResponseParseError(
2136
2236
  `Failed to parse AI response as JSON: ${raw.slice(0, 200)}`,
@@ -2225,7 +2325,8 @@ function visualAI(config = {}) {
2225
2325
  model: resolvedConfig.model,
2226
2326
  maxTokens: resolvedConfig.maxTokens,
2227
2327
  reasoningEffort: resolvedConfig.reasoningEffort,
2228
- imageDetail: resolvedConfig.imageDetail
2328
+ imageDetail: resolvedConfig.imageDetail,
2329
+ timeout: resolvedConfig.timeout
2229
2330
  };
2230
2331
  const driver = createDriver(resolvedConfig.provider, driverConfig);
2231
2332
  const maxImageDimension = resolvedConfig.maxImageDimension;