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/README.md +59 -24
- package/dist/index.cjs +122 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -1
- package/dist/index.d.ts +45 -1
- package/dist/index.js +122 -21
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
425
|
-
|
|
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
|
|
428
|
-
|
|
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
|
|
432
|
-
const
|
|
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
|
-
|
|
435
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
1064
|
-
|
|
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 ${
|
|
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 =
|
|
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;
|