visual-ai-assertions 0.21.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/README.md CHANGED
@@ -104,6 +104,7 @@ const ai = visualAI({
104
104
  debug: true, // optional, logs prompts/responses to stderr
105
105
  maxTokens: 4096, // optional, default 4096
106
106
  reasoningEffort: "high", // optional, "low" | "medium" | "high" | "xhigh"
107
+ timeout: 120_000, // optional, ms — defaults to the provider SDK's own timeout
107
108
  trackUsage: false, // optional, defaults to false — usage stats to stderr
108
109
  });
109
110
 
@@ -255,6 +256,33 @@ await ai.elementsVisible(screenshot, ["Submit button", "Nav bar", "Footer"]);
255
256
  // Check that UI elements are hidden
256
257
  await ai.elementsHidden(screenshot, ["Loading spinner", "Error modal"]);
257
258
 
259
+ // Clipping is judged the way a tester would. An element cut off at the trailing
260
+ // edge of a scrollable row or feed passes, because scrolling reaches it. One
261
+ // sliced by the screen edge or by fixed chrome such as the status bar or a
262
+ // sticky nav fails, because scrolling cannot. An element you cannot see at all
263
+ // fails: only what the screenshot shows is judged.
264
+ //
265
+ // Overlays are judged by what they say about the element's state, not by how
266
+ // much they cover. A badge, favourite icon or price pill coexists with finished
267
+ // content and leaves the element visible. A loading spinner, skeleton, progress
268
+ // bar or error overlay says it is not ready, so the check fails even though you
269
+ // can still see what is underneath. A modal, dialog or cookie banner that a user
270
+ // could not read or use past also fails.
271
+ //
272
+ // Pass finalState: false when the screenshot was deliberately captured mid-load,
273
+ // so loading chrome is expected rather than a defect. The finished-state rules
274
+ // are left out; presence, clipping and blocking overlays are still judged.
275
+ await ai.elementsVisible(screenshot, ["Product image"], { finalState: false });
276
+
277
+ // By default this is a presence check: an element counts as visible if it is
278
+ // there at all, whatever it looks like. Pass requireCorrectRendering: true to
279
+ // also fail an element that is present but clearly badly rendered — unreadable
280
+ // contrast, overlapping or misaligned elements, text cut off mid-word — with
281
+ // the model saying the element is present before naming the defect. Opt in per
282
+ // assertion: measured on the bench, it catches exactly that case and makes
283
+ // models flakier on plain presence questions.
284
+ await ai.elementsVisible(screenshot, ["Promo banner"], { requireCorrectRendering: true });
285
+
258
286
  // Accessibility checks (contrast, readability, interactive visibility, color blindness, color-alone meaning)
259
287
  await ai.accessibility(screenshot);
260
288
  await ai.accessibility(screenshot, {
@@ -463,16 +491,17 @@ The `VisualAIKnownError` union and `isVisualAIKnownError()` helper are useful wh
463
491
 
464
492
  ## Configuration
465
493
 
466
- | Option | Type | Default | Description |
467
- | ----------------- | ------- | ---------------- | ----------------------------------------------------------------------------- |
468
- | `apiKey` | string | env var | API key for the provider |
469
- | `model` | string | provider default | Model to use |
470
- | `debug` | boolean | `false` | Enable error diagnostic logging to stderr |
471
- | `debugPrompt` | boolean | `false` | Log prompts to stderr |
472
- | `debugResponse` | boolean | `false` | Log responses to stderr |
473
- | `maxTokens` | number | `4096` | Max tokens for AI response |
474
- | `reasoningEffort` | string | `undefined` | `"low"` `"medium"` `"high"` `"xhigh"` — controls how deeply the model reasons |
475
- | `trackUsage` | boolean | `false` | Log token usage and estimated cost to stderr |
494
+ | Option | Type | Default | Description |
495
+ | ----------------- | ------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
496
+ | `apiKey` | string | env var | API key for the provider |
497
+ | `model` | string | provider default | Model to use |
498
+ | `debug` | boolean | `false` | Enable error diagnostic logging to stderr |
499
+ | `debugPrompt` | boolean | `false` | Log prompts to stderr |
500
+ | `debugResponse` | boolean | `false` | Log responses to stderr |
501
+ | `maxTokens` | number | `4096` | Max tokens for AI response |
502
+ | `reasoningEffort` | string | `undefined` | `"low"` `"medium"` `"high"` `"xhigh"` — controls how deeply the model reasons |
503
+ | `timeout` | number | SDK default | Per-request timeout in ms. Unset leaves each SDK's own default (OpenAI/OpenRouter 10 min, Google 1 min). SDKs retry timeouts, so total wall time can be a multiple of this. |
504
+ | `trackUsage` | boolean | `false` | Log token usage and estimated cost to stderr |
476
505
 
477
506
  ## Exported Types
478
507
 
@@ -535,7 +564,8 @@ All listed models support image/vision input. Pass any model ID to the `model` c
535
564
 
536
565
  | Model | Model ID | Input $/MTok | Output $/MTok | Notes |
537
566
  | ----------------- | ------------------- | ------------ | ------------- | ------------------------------------------- |
538
- | Claude Fable 5 | `claude-fable-5` | $10 | $50 | Most capable; long-horizon agentic work |
567
+ | Claude Fable 5.1 | `claude-fable-5-1` | $10 | $50 | Most capable; long-horizon agentic work |
568
+ | Claude Fable 5 | `claude-fable-5` | $10 | $50 | Predecessor to Fable 5.1, same price |
539
569
  | Claude Opus 4.8 | `claude-opus-4-8` | $5 | $25 | Most capable Opus tier; supports `xhigh` |
540
570
  | Claude Opus 4.7 | `claude-opus-4-7` | $5 | $25 | Previous Opus; supports `xhigh` effort tier |
541
571
  | Claude Opus 4.6 | `claude-opus-4-6` | $5 | $25 | Previous flagship, 128K max output |
@@ -547,6 +577,7 @@ All listed models support image/vision input. Pass any model ID to the `model` c
547
577
 
548
578
  | Model | Model ID | Input $/MTok | Output $/MTok | Notes |
549
579
  | ------------- | --------------- | ------------ | ------------- | -------------------------------------- |
580
+ | GPT-6 Astra | `gpt-6-astra` | $10 | $50 | Most capable; restricted access¹ |
550
581
  | GPT-5.6 Sol | `gpt-5.6-sol` | $5 | $30 | Newest flagship, frontier tier |
551
582
  | GPT-5.6 Terra | `gpt-5.6-terra` | $2 | $12 | Newest balanced, everyday tier |
552
583
  | GPT-5.6 Luna | `gpt-5.6-luna` | $0.20 | $1.20 | **Default** — newest, fastest/cheapest |
@@ -558,6 +589,10 @@ All listed models support image/vision input. Pass any model ID to the `model` c
558
589
  | GPT-5.4 nano | `gpt-5.4-nano` | $0.20 | $1.25 | Cheapest older-generation option |
559
590
  | GPT-5 mini | `gpt-5-mini` | $0.25 | $2 | Fast and cheap |
560
591
 
592
+ ¹ GPT-6 Astra is rolling out through OpenAI's Trusted Access Program, so many API keys cannot reach it yet — expect a `VisualAIProviderError` naming the model until your account is enabled.
593
+
594
+ Astra reasons heavily enough to spend the entire 4096-token default output budget before emitting an answer, so **it is given a 32768-token budget automatically**, at every reasoning effort rather than only at `high`/`xhigh` like other OpenAI models. That follows OpenAI's guidance to reserve at least 25,000 tokens for reasoning and output. Its output length is erratic — identical calls have used anywhere from 0 to 16384+ reasoning tokens — so a large budget reduces truncation without eliminating it; a call that exhausts the budget still bills for the tokens it burned. Passing `maxTokens` explicitly still wins. It also accepts a fifth reasoning level, `max`, above `xhigh`; this library's `reasoningEffort` stops at `xhigh`, which is passed through unchanged, so `max` is not currently reachable.
595
+
561
596
  ### Google
562
597
 
563
598
  | Model | Model ID | Input $/MTok | Output $/MTok | Notes |
package/dist/index.cjs CHANGED
@@ -89,6 +89,7 @@ var Provider = {
89
89
  };
90
90
  var Model = {
91
91
  Anthropic: {
92
+ FABLE_5_1: "claude-fable-5-1",
92
93
  FABLE_5: "claude-fable-5",
93
94
  OPUS_5: "claude-opus-5",
94
95
  OPUS_4_8: "claude-opus-4-8",
@@ -99,6 +100,7 @@ var Model = {
99
100
  HAIKU_4_5: "claude-haiku-4-5"
100
101
  },
101
102
  OpenAI: {
103
+ GPT_6_ASTRA: "gpt-6-astra",
102
104
  GPT_5_6_SOL: "gpt-5.6-sol",
103
105
  GPT_5_6_TERRA: "gpt-5.6-terra",
104
106
  GPT_5_6_LUNA: "gpt-5.6-luna",
@@ -144,6 +146,10 @@ var DEFAULT_MODELS = {
144
146
  };
145
147
  var DEFAULT_MAX_TOKENS = 4096;
146
148
  var OPENAI_REASONING_MAX_TOKENS = 16384;
149
+ var OPENAI_HEAVY_REASONING_MAX_TOKENS = 32768;
150
+ var MODELS_REQUIRING_LARGE_OUTPUT_BUDGET = /* @__PURE__ */ new Set([
151
+ Model.OpenAI.GPT_6_ASTRA
152
+ ]);
147
153
  var MODEL_TO_PROVIDER = new Map([
148
154
  ...Object.values(Model.Anthropic).map((m) => [m, Provider.ANTHROPIC]),
149
155
  ...Object.values(Model.OpenAI).map((m) => [m, Provider.OPENAI]),
@@ -488,22 +494,50 @@ function buildComparePrompt(options) {
488
494
  }
489
495
 
490
496
  // src/templates/elements-visibility.ts
491
- var ELEMENTS_VISIBLE_ROLE = "Check whether specific UI elements are present and fully visible in this screenshot.";
492
497
  var ELEMENTS_HIDDEN_ROLE = "Check whether specific UI elements are absent or hidden in this screenshot.";
493
- var ELEMENTS_VISIBLE_EDGE_RULES = [
494
- "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."
498
+ function joinClauses(clauses) {
499
+ if (clauses.length <= 1) return clauses[0] ?? "";
500
+ if (clauses.length === 2) return `${clauses[0]} and ${clauses[1]}`;
501
+ return `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
502
+ }
503
+ function visibleRole(finalState, requireCorrectRendering) {
504
+ const clauses = ["present", "properly visible"];
505
+ if (requireCorrectRendering) clauses.push("correctly rendered");
506
+ if (finalState) clauses.push("in their finished state");
507
+ return `Check whether specific UI elements are ${joinClauses(clauses)} in this screenshot.`;
508
+ }
509
+ var ELEMENTS_VISIBLE_CLIPPING_RULES = [
510
+ "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.",
511
+ "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.",
512
+ "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."
495
513
  ];
496
- var ELEMENTS_HIDDEN_EDGE_RULES = [
497
- "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."
514
+ 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.";
515
+ 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.";
516
+ 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.";
517
+ function visibleRules(finalState, requireCorrectRendering) {
518
+ return [
519
+ ...ELEMENTS_VISIBLE_CLIPPING_RULES,
520
+ ...finalState ? [ELEMENTS_VISIBLE_FINAL_STATE_RULE] : [],
521
+ ...requireCorrectRendering ? [ELEMENTS_VISIBLE_CORRECT_RENDERING_RULE] : [],
522
+ ELEMENTS_VISIBLE_OCCLUSION_RULE
523
+ ];
524
+ }
525
+ var ELEMENTS_HIDDEN_BASE_RULES = [
526
+ "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.",
527
+ "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."
498
528
  ];
529
+ 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.";
530
+ function hiddenRules(finalState) {
531
+ return finalState ? [...ELEMENTS_HIDDEN_BASE_RULES, ELEMENTS_HIDDEN_FINAL_STATE_RULE] : ELEMENTS_HIDDEN_BASE_RULES;
532
+ }
499
533
  function buildElementsVisibilityPrompt(elements, visible, options) {
500
- 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`);
501
- const defaultRules = visible ? ELEMENTS_VISIBLE_EDGE_RULES : ELEMENTS_HIDDEN_EDGE_RULES;
534
+ 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`);
535
+ const finalState = options?.finalState ?? true;
536
+ const correctRendering = visible && (options?.requireCorrectRendering ?? false);
537
+ const defaultRules = visible ? visibleRules(finalState, correctRendering) : hiddenRules(finalState);
502
538
  const instructions = options?.instructions ? [...defaultRules, ...options.instructions] : defaultRules;
503
- return buildCheckPrompt(statements, {
504
- role: visible ? ELEMENTS_VISIBLE_ROLE : ELEMENTS_HIDDEN_ROLE,
505
- instructions
506
- });
539
+ const role = visible ? visibleRole(finalState, correctRendering) : ELEMENTS_HIDDEN_ROLE;
540
+ return buildCheckPrompt(statements, { role, instructions });
507
541
  }
508
542
 
509
543
  // src/templates/accessibility.ts
@@ -613,6 +647,7 @@ function parseRetryAfter(value) {
613
647
 
614
648
  // src/providers/anthropic.ts
615
649
  var XHIGH_CAPABLE_MODELS = /* @__PURE__ */ new Set([
650
+ Model.Anthropic.FABLE_5_1,
616
651
  Model.Anthropic.FABLE_5,
617
652
  Model.Anthropic.OPUS_5,
618
653
  Model.Anthropic.OPUS_4_8,
@@ -636,12 +671,14 @@ var AnthropicDriver = class {
636
671
  maxTokens;
637
672
  apiKeyOrEnv;
638
673
  reasoningEffort;
674
+ timeout;
639
675
  constructor(config) {
640
676
  this.model = config.model;
641
677
  this.maxTokens = config.maxTokens;
642
678
  this.client = null;
643
679
  this.apiKeyOrEnv = config.apiKey;
644
680
  this.reasoningEffort = config.reasoningEffort;
681
+ this.timeout = config.timeout;
645
682
  }
646
683
  async getClient() {
647
684
  if (this.client) return this.client;
@@ -660,7 +697,10 @@ var AnthropicDriver = class {
660
697
  "Anthropic API key not found. Set ANTHROPIC_API_KEY or pass apiKey in config."
661
698
  );
662
699
  }
663
- this.client = new Anthropic({ apiKey });
700
+ this.client = new Anthropic({
701
+ apiKey,
702
+ ...this.timeout !== void 0 && { timeout: this.timeout }
703
+ });
664
704
  return this.client;
665
705
  }
666
706
  async sendMessage(images, prompt, _options) {
@@ -758,6 +798,7 @@ var GoogleDriver = class {
758
798
  apiKeyOrEnv;
759
799
  reasoningEffort;
760
800
  imageDetail;
801
+ timeout;
761
802
  constructor(config) {
762
803
  this.model = config.model;
763
804
  this.maxTokens = config.maxTokens;
@@ -765,6 +806,7 @@ var GoogleDriver = class {
765
806
  this.apiKeyOrEnv = config.apiKey;
766
807
  this.reasoningEffort = config.reasoningEffort;
767
808
  this.imageDetail = config.imageDetail;
809
+ this.timeout = config.timeout;
768
810
  }
769
811
  toGeminiParts(images) {
770
812
  return images.map((img) => ({
@@ -788,7 +830,10 @@ var GoogleDriver = class {
788
830
  "Google API key not found. Set GOOGLE_API_KEY or pass apiKey in config."
789
831
  );
790
832
  }
791
- this.client = new GoogleGenAI({ apiKey });
833
+ this.client = new GoogleGenAI({
834
+ apiKey,
835
+ ...this.timeout !== void 0 && { httpOptions: { timeout: this.timeout } }
836
+ });
792
837
  return this.client;
793
838
  }
794
839
  async sendMessage(images, prompt, _options) {
@@ -874,6 +919,7 @@ var OpenAIDriver = class {
874
919
  apiKeyOrEnv;
875
920
  reasoningEffort;
876
921
  imageDetail;
922
+ timeout;
877
923
  constructor(config) {
878
924
  this.model = config.model;
879
925
  this.maxTokens = config.maxTokens;
@@ -881,6 +927,7 @@ var OpenAIDriver = class {
881
927
  this.apiKeyOrEnv = config.apiKey;
882
928
  this.reasoningEffort = config.reasoningEffort;
883
929
  this.imageDetail = config.imageDetail;
930
+ this.timeout = config.timeout;
884
931
  }
885
932
  async getClient() {
886
933
  if (this.client) return this.client;
@@ -897,7 +944,10 @@ var OpenAIDriver = class {
897
944
  "OpenAI API key not found. Set OPENAI_API_KEY or pass apiKey in config."
898
945
  );
899
946
  }
900
- this.client = new OpenAI({ apiKey });
947
+ this.client = new OpenAI({
948
+ apiKey,
949
+ ...this.timeout !== void 0 && { timeout: this.timeout }
950
+ });
901
951
  return this.client;
902
952
  }
903
953
  async sendMessage(images, prompt, options) {
@@ -972,6 +1022,7 @@ var OpenRouterDriver = class {
972
1022
  apiKeyOrEnv;
973
1023
  reasoningEffort;
974
1024
  imageDetail;
1025
+ timeout;
975
1026
  constructor(config) {
976
1027
  this.model = config.model;
977
1028
  this.maxTokens = config.maxTokens;
@@ -979,6 +1030,7 @@ var OpenRouterDriver = class {
979
1030
  this.apiKeyOrEnv = config.apiKey;
980
1031
  this.reasoningEffort = config.reasoningEffort;
981
1032
  this.imageDetail = config.imageDetail;
1033
+ this.timeout = config.timeout;
982
1034
  }
983
1035
  async getClient() {
984
1036
  if (this.client) return this.client;
@@ -997,7 +1049,11 @@ var OpenRouterDriver = class {
997
1049
  "OpenRouter API key not found. Set OPENROUTER_API_KEY or pass apiKey in config."
998
1050
  );
999
1051
  }
1000
- this.client = new OpenAI({ apiKey, baseURL: OPENROUTER_BASE_URL });
1052
+ this.client = new OpenAI({
1053
+ apiKey,
1054
+ baseURL: OPENROUTER_BASE_URL,
1055
+ ...this.timeout !== void 0 && { timeout: this.timeout }
1056
+ });
1001
1057
  return this.client;
1002
1058
  }
1003
1059
  async sendMessage(images, prompt, options) {
@@ -1127,13 +1183,21 @@ function resolveConfig(config) {
1127
1183
  `
1128
1184
  );
1129
1185
  }
1186
+ if (config.timeout !== void 0 && (!Number.isFinite(config.timeout) || config.timeout <= 0)) {
1187
+ throw new VisualAIConfigError(
1188
+ `Invalid timeout: ${config.timeout}. Must be a positive number of milliseconds.`
1189
+ );
1190
+ }
1130
1191
  const userSetMaxTokens = config.maxTokens !== void 0;
1131
1192
  let maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
1132
- if (!userSetMaxTokens && (provider === "openai" || provider === "openrouter") && (config.reasoningEffort === "high" || config.reasoningEffort === "xhigh")) {
1133
- maxTokens = OPENAI_REASONING_MAX_TOKENS;
1193
+ const effortNeedsLargeBudget = config.reasoningEffort === "high" || config.reasoningEffort === "xhigh";
1194
+ const modelNeedsLargeBudget = MODELS_REQUIRING_LARGE_OUTPUT_BUDGET.has(model);
1195
+ if (!userSetMaxTokens && (provider === "openai" || provider === "openrouter") && (effortNeedsLargeBudget || modelNeedsLargeBudget)) {
1196
+ maxTokens = modelNeedsLargeBudget ? OPENAI_HEAVY_REASONING_MAX_TOKENS : OPENAI_REASONING_MAX_TOKENS;
1134
1197
  if (debug) {
1198
+ const reason = modelNeedsLargeBudget ? `model "${model}", which exhausts smaller budgets on reasoning at any effort` : `provider "${provider}" with reasoningEffort "${config.reasoningEffort}"`;
1135
1199
  process.stderr.write(
1136
- `[visual-ai-assertions] Auto-increased maxTokens from ${DEFAULT_MAX_TOKENS} to ${OPENAI_REASONING_MAX_TOKENS} for provider "${provider}" with reasoningEffort "${config.reasoningEffort}".
1200
+ `[visual-ai-assertions] Auto-increased maxTokens from ${DEFAULT_MAX_TOKENS} to ${maxTokens} for ${reason}.
1137
1201
  `
1138
1202
  );
1139
1203
  }
@@ -1146,6 +1210,7 @@ function resolveConfig(config) {
1146
1210
  reasoningEffort: config.reasoningEffort,
1147
1211
  maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION,
1148
1212
  imageDetail: config.imageDetail ?? DEFAULT_IMAGE_DETAIL,
1213
+ timeout: config.timeout,
1149
1214
  debug,
1150
1215
  debugPrompt,
1151
1216
  debugResponse,
@@ -1156,6 +1221,11 @@ function resolveConfig(config) {
1156
1221
  // src/core/pricing.ts
1157
1222
  var PER_MILLION = 1e6;
1158
1223
  var PRICING_TABLE = {
1224
+ // Fable 5.1 is priced identically to Fable 5, the tier it succeeds.
1225
+ [`${Provider.ANTHROPIC}:${Model.Anthropic.FABLE_5_1}`]: {
1226
+ inputPricePerToken: 10 / PER_MILLION,
1227
+ outputPricePerToken: 50 / PER_MILLION
1228
+ },
1159
1229
  [`${Provider.ANTHROPIC}:${Model.Anthropic.FABLE_5}`]: {
1160
1230
  inputPricePerToken: 10 / PER_MILLION,
1161
1231
  outputPricePerToken: 50 / PER_MILLION
@@ -1188,6 +1258,12 @@ var PRICING_TABLE = {
1188
1258
  inputPricePerToken: 1 / PER_MILLION,
1189
1259
  outputPricePerToken: 5 / PER_MILLION
1190
1260
  },
1261
+ // Cached input is $1/MTok and cache writes $12.50/MTok; neither is modelled
1262
+ // here, since `calculateCost` applies no cache discount on any provider.
1263
+ [`${Provider.OPENAI}:${Model.OpenAI.GPT_6_ASTRA}`]: {
1264
+ inputPricePerToken: 10 / PER_MILLION,
1265
+ outputPricePerToken: 50 / PER_MILLION
1266
+ },
1191
1267
  [`${Provider.OPENAI}:${Model.OpenAI.GPT_5_6_SOL}`]: {
1192
1268
  inputPricePerToken: 5 / PER_MILLION,
1193
1269
  outputPricePerToken: 30 / PER_MILLION
@@ -2196,10 +2272,34 @@ function stripCodeFences(text) {
2196
2272
  var CheckResponseSchema = CheckResultSchema.omit({ usage: true });
2197
2273
  var AskResponseSchema = AskResultSchema.omit({ usage: true });
2198
2274
  var CompareResponseSchema = CompareResultSchema.omit({ usage: true });
2275
+ var STRAY_CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
2276
+ function parseJson(text) {
2277
+ try {
2278
+ return JSON.parse(text);
2279
+ } catch (first) {
2280
+ try {
2281
+ return JSON.parse(text.replace(/[\u0000-\u001f]/g, " "));
2282
+ } catch {
2283
+ throw first;
2284
+ }
2285
+ }
2286
+ }
2287
+ function stripControlCharacters(value) {
2288
+ if (typeof value === "string") return value.replace(STRAY_CONTROL_CHARS, "");
2289
+ if (Array.isArray(value)) return value.map((item) => stripControlCharacters(item));
2290
+ if (value !== null && typeof value === "object") {
2291
+ const out = {};
2292
+ for (const [key, item] of Object.entries(value)) {
2293
+ out[key] = stripControlCharacters(item);
2294
+ }
2295
+ return out;
2296
+ }
2297
+ return value;
2298
+ }
2199
2299
  function parseResponse(raw, schema) {
2200
2300
  let parsed;
2201
2301
  try {
2202
- parsed = JSON.parse(stripCodeFences(raw));
2302
+ parsed = stripControlCharacters(parseJson(stripCodeFences(raw)));
2203
2303
  } catch {
2204
2304
  throw new VisualAIResponseParseError(
2205
2305
  `Failed to parse AI response as JSON: ${raw.slice(0, 200)}`,
@@ -2294,7 +2394,8 @@ function visualAI(config = {}) {
2294
2394
  model: resolvedConfig.model,
2295
2395
  maxTokens: resolvedConfig.maxTokens,
2296
2396
  reasoningEffort: resolvedConfig.reasoningEffort,
2297
- imageDetail: resolvedConfig.imageDetail
2397
+ imageDetail: resolvedConfig.imageDetail,
2398
+ timeout: resolvedConfig.timeout
2298
2399
  };
2299
2400
  const driver = createDriver(resolvedConfig.provider, driverConfig);
2300
2401
  const maxImageDimension = resolvedConfig.maxImageDimension;