argus-reviewer-e2e 0.1.3 → 0.2.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 +29 -5
- package/action/action.yml +17 -0
- package/action/sticky-comment.mjs +270 -90
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/cli.d.ts +67 -0
- package/dist/cli.js +383 -49
- package/dist/config.d.ts +63 -2
- package/dist/config.js +108 -5
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +9 -3
- package/dist/evidence/ci.d.ts +3 -0
- package/dist/evidence/ci.js +3 -1
- package/dist/probe/queue.d.ts +4 -1
- package/dist/probe/queue.js +46 -8
- package/dist/review/adjudicate.d.ts +63 -0
- package/dist/review/adjudicate.js +111 -0
- package/dist/review/secrets.d.ts +88 -0
- package/dist/review/secrets.js +220 -0
- package/dist/review/triage.d.ts +76 -0
- package/dist/review/triage.js +163 -0
- package/dist/trust.d.ts +50 -0
- package/dist/trust.js +103 -0
- package/dist/vision/cost.d.ts +14 -1
- package/dist/vision/cost.js +13 -0
- package/dist/vision/decisions.d.ts +95 -0
- package/dist/vision/decisions.js +232 -0
- package/package.json +3 -2
package/dist/trust.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
async function readEventPayload(env, readEventFile) {
|
|
3
|
+
const eventPath = env.GITHUB_EVENT_PATH;
|
|
4
|
+
if (eventPath === undefined || eventPath === '')
|
|
5
|
+
return undefined;
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(await readEventFile(eventPath));
|
|
8
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function tracePr(env) {
|
|
18
|
+
const raw = env.ARGUS_REVIEWER_TRACE;
|
|
19
|
+
if (raw === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
return typeof parsed.pr === 'string' && parsed.pr !== '' ? parsed.pr : undefined;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function fetchMetaFor(opts, prOverride) {
|
|
30
|
+
const { env, fetchMeta } = opts;
|
|
31
|
+
if (fetchMeta === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
34
|
+
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
35
|
+
const pr = prOverride ?? tracePr(env);
|
|
36
|
+
if (repo === undefined || token === undefined || pr === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
return fetchMeta(repo, pr, token);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolves whether the checked-out tree may execute config code.
|
|
42
|
+
*
|
|
43
|
+
* - `pull_request*` events: fork status from the event payload — no token
|
|
44
|
+
* needed (`head.repo.fork`). Payload absent/unreadable → `fetchMeta`
|
|
45
|
+
* fallback → still unknown → untrusted.
|
|
46
|
+
* - `issue_comment`: `fetchMeta` on `issue.number`; unavailable → untrusted.
|
|
47
|
+
* - Any other present `GITHUB_EVENT_NAME` (`workflow_run`, `push`,
|
|
48
|
+
* `workflow_dispatch`, …): untrusted — unlisted CI events fail closed
|
|
49
|
+
* because privileged-CI-over-fork-checkout patterns (workflow_run over a
|
|
50
|
+
* fork SHA) land exactly there. Maintainers opt out explicitly with
|
|
51
|
+
* `ARGUS_TRUSTED=1`.
|
|
52
|
+
* - No event env at all (local run): trusted unless `ARGUS_UNTRUSTED=1`.
|
|
53
|
+
* - `ARGUS_UNTRUSTED=1` always wins; `ARGUS_TRUSTED=1` overrides event
|
|
54
|
+
* resolution but never `ARGUS_UNTRUSTED`.
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveTrust(opts) {
|
|
57
|
+
const { env, note } = opts;
|
|
58
|
+
const readEventFile = opts.readEventFile ?? ((p) => readFile(p, 'utf8'));
|
|
59
|
+
const done = (trust, reason, pr) => {
|
|
60
|
+
note?.(`trust: ${trust} — ${reason}`);
|
|
61
|
+
return { trust, reason, pr };
|
|
62
|
+
};
|
|
63
|
+
if (env.ARGUS_UNTRUSTED === '1') {
|
|
64
|
+
return done('untrusted', 'ARGUS_UNTRUSTED=1');
|
|
65
|
+
}
|
|
66
|
+
if (env.ARGUS_TRUSTED === '1') {
|
|
67
|
+
return done('trusted', 'ARGUS_TRUSTED=1 override');
|
|
68
|
+
}
|
|
69
|
+
const eventName = env.GITHUB_EVENT_NAME;
|
|
70
|
+
if (eventName === undefined || eventName === '') {
|
|
71
|
+
return done('trusted', 'local run — no CI event context');
|
|
72
|
+
}
|
|
73
|
+
const payload = await readEventPayload(env, readEventFile);
|
|
74
|
+
if (eventName === 'pull_request' || eventName === 'pull_request_target') {
|
|
75
|
+
const fork = payload?.pull_request?.head?.repo?.fork;
|
|
76
|
+
if (fork === true)
|
|
77
|
+
return done('untrusted', 'fork PR head checkout');
|
|
78
|
+
if (fork === false)
|
|
79
|
+
return done('trusted', 'same-repo PR checkout');
|
|
80
|
+
// Payload absent/unreadable (or head.repo null — a deleted source fork
|
|
81
|
+
// fails closed the same way evidence/ci.ts's isFork does): try the API.
|
|
82
|
+
const meta = await fetchMetaFor(opts);
|
|
83
|
+
if (meta !== undefined) {
|
|
84
|
+
return meta.isFork
|
|
85
|
+
? done('untrusted', 'fork PR (metadata)')
|
|
86
|
+
: done('trusted', 'same-repo PR (metadata)');
|
|
87
|
+
}
|
|
88
|
+
return done('untrusted', 'PR fork status unavailable — failing closed');
|
|
89
|
+
}
|
|
90
|
+
if (eventName === 'issue_comment') {
|
|
91
|
+
const pr = payload?.issue?.pull_request !== undefined && typeof payload.issue?.number === 'number'
|
|
92
|
+
? String(payload.issue.number)
|
|
93
|
+
: undefined;
|
|
94
|
+
const meta = await fetchMetaFor(opts, pr);
|
|
95
|
+
if (meta !== undefined) {
|
|
96
|
+
return meta.isFork
|
|
97
|
+
? done('untrusted', 'issue_comment on fork PR', pr)
|
|
98
|
+
: done('trusted', 'issue_comment on same-repo PR', pr);
|
|
99
|
+
}
|
|
100
|
+
return done('untrusted', 'issue_comment PR metadata unavailable — failing closed', pr);
|
|
101
|
+
}
|
|
102
|
+
return done('untrusted', `unlisted CI event "${eventName}" — failing closed`);
|
|
103
|
+
}
|
package/dist/vision/cost.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type CallKind = 'ground' | 'heal' | 'assert' | 'code';
|
|
1
|
+
export type CallKind = 'ground' | 'heal' | 'assert' | 'code' | 'decide';
|
|
2
2
|
export interface CallCost {
|
|
3
3
|
model: string;
|
|
4
4
|
provider: string;
|
|
@@ -30,6 +30,19 @@ export interface OpenRouterResponse {
|
|
|
30
30
|
usage: Usage;
|
|
31
31
|
}
|
|
32
32
|
export declare function makeCallCost(response: OpenRouterResponse, kind: CallKind): CallCost;
|
|
33
|
+
/** Decisions API (`/api/alpha/decisions`) usage shape — no `cost_details`. */
|
|
34
|
+
export interface DecisionsResponse {
|
|
35
|
+
id?: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
provider?: ProviderValue;
|
|
38
|
+
answers?: Record<string, unknown>;
|
|
39
|
+
usage?: {
|
|
40
|
+
input_tokens?: number;
|
|
41
|
+
output_tokens?: number;
|
|
42
|
+
cost?: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export declare function makeDecisionsCallCost(response: DecisionsResponse, kind: CallKind): CallCost;
|
|
33
46
|
export declare function extractUsageCost(response: OpenRouterResponse): {
|
|
34
47
|
costUsd: number;
|
|
35
48
|
upstreamCostUsd: number;
|
package/dist/vision/cost.js
CHANGED
|
@@ -8,6 +8,19 @@ export function makeCallCost(response, kind) {
|
|
|
8
8
|
kind,
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
|
+
export function makeDecisionsCallCost(response, kind) {
|
|
12
|
+
const providerName = typeof response.provider === 'string' ? response.provider : response.provider?.name ?? 'unknown';
|
|
13
|
+
const usage = response.usage ?? {};
|
|
14
|
+
// Coerce — a string cost would throw downstream at toFixed and discard
|
|
15
|
+
// a valid decision; a string token count would concatenate.
|
|
16
|
+
return {
|
|
17
|
+
model: response.model ?? 'unknown',
|
|
18
|
+
provider: providerName,
|
|
19
|
+
tokens: (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0),
|
|
20
|
+
costUsd: Number(usage.cost) || 0,
|
|
21
|
+
kind,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
11
24
|
export function extractUsageCost(response) {
|
|
12
25
|
return {
|
|
13
26
|
costUsd: response.usage.cost,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CallCost, ProviderValue } from './cost.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pinned Jev slug — the alias `~typesafe/jev-latest` drifts silently and
|
|
4
|
+
* adjudication thresholds are calibrated to a version. The alias stays
|
|
5
|
+
* usable via `config.decisionModel` for experimentation.
|
|
6
|
+
*/
|
|
7
|
+
export declare const JEV_DEFAULT_MODEL = "typesafe/jev-1.13-20260917";
|
|
8
|
+
export type DecisionErrorKind = 'auth' | 'validation' | 'rate_limited' | 'overloaded' | 'server_error' | 'timeout' | 'unexpected';
|
|
9
|
+
export declare class DecisionError extends Error {
|
|
10
|
+
readonly kind: DecisionErrorKind;
|
|
11
|
+
readonly retryable: boolean;
|
|
12
|
+
constructor(kind: DecisionErrorKind, message: string, retryable: boolean);
|
|
13
|
+
}
|
|
14
|
+
export interface NoulQuestion {
|
|
15
|
+
type: 'noul';
|
|
16
|
+
instructions: string;
|
|
17
|
+
/** Optional yes/no clarifications, sent verbatim. */
|
|
18
|
+
true?: string;
|
|
19
|
+
false?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ChoiceQuestion {
|
|
22
|
+
type: 'choice';
|
|
23
|
+
instructions: string;
|
|
24
|
+
/** Option ID -> description. Up to 255 options. */
|
|
25
|
+
criteria: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
export interface ScoreQuestion {
|
|
28
|
+
type: 'score';
|
|
29
|
+
instructions: string;
|
|
30
|
+
/** 2-10 ordered rubric levels, low to high. */
|
|
31
|
+
criteria: string[];
|
|
32
|
+
}
|
|
33
|
+
export type DecisionQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
|
|
34
|
+
export interface NoulAnswer {
|
|
35
|
+
noul: number;
|
|
36
|
+
}
|
|
37
|
+
export interface ChoiceAnswer {
|
|
38
|
+
choice: string;
|
|
39
|
+
probabilities?: Record<string, number>;
|
|
40
|
+
confidence?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface ScoreAnswer {
|
|
43
|
+
score: number;
|
|
44
|
+
legend?: string[];
|
|
45
|
+
probabilities?: number[];
|
|
46
|
+
confidence?: number;
|
|
47
|
+
}
|
|
48
|
+
export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
|
|
49
|
+
/** Type guards over the answer union — one `in` check per lane otherwise. */
|
|
50
|
+
export declare const isNoulAnswer: (a: DecisionAnswer) => a is NoulAnswer;
|
|
51
|
+
export declare const isChoiceAnswer: (a: DecisionAnswer) => a is ChoiceAnswer;
|
|
52
|
+
export declare const isScoreAnswer: (a: DecisionAnswer) => a is ScoreAnswer;
|
|
53
|
+
/** Short error label for lane debug lines: DecisionError kind, else message. */
|
|
54
|
+
export declare function describeDecisionError(e: unknown): string;
|
|
55
|
+
/** Shared per-call batch cap for the Jev lanes (secrets, findings, triage). */
|
|
56
|
+
export declare const MAX_CANDIDATES = 50;
|
|
57
|
+
export interface DecisionClientOptions {
|
|
58
|
+
apiKey: string;
|
|
59
|
+
fetch?: typeof fetch;
|
|
60
|
+
trace?: Record<string, string>;
|
|
61
|
+
/** Request timeout in ms. Default 15_000. */
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
onCall?: (call: {
|
|
64
|
+
id: string;
|
|
65
|
+
model: string;
|
|
66
|
+
provider: string;
|
|
67
|
+
kind: 'decide';
|
|
68
|
+
costUsd: number;
|
|
69
|
+
tokens: number;
|
|
70
|
+
trace?: Record<string, string>;
|
|
71
|
+
}) => void;
|
|
72
|
+
}
|
|
73
|
+
export declare class DecisionClient {
|
|
74
|
+
private _apiKey;
|
|
75
|
+
private _fetch;
|
|
76
|
+
private _trace;
|
|
77
|
+
private _timeoutMs;
|
|
78
|
+
private _onCall;
|
|
79
|
+
constructor(opts: DecisionClientOptions);
|
|
80
|
+
/**
|
|
81
|
+
* Ask typed questions about `state`. Throws a typed `DecisionError` on
|
|
82
|
+
* any failure — callers catch and degrade to unadjudicated; a decision
|
|
83
|
+
* failure must never suppress findings (KTD6).
|
|
84
|
+
*/
|
|
85
|
+
decide(opts: {
|
|
86
|
+
model?: string;
|
|
87
|
+
state: unknown;
|
|
88
|
+
questions: Record<string, DecisionQuestion>;
|
|
89
|
+
}): Promise<{
|
|
90
|
+
answers: Record<string, DecisionAnswer>;
|
|
91
|
+
cost: CallCost;
|
|
92
|
+
model: string;
|
|
93
|
+
}>;
|
|
94
|
+
}
|
|
95
|
+
export type { ProviderValue };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { debug } from '../debug.js';
|
|
2
|
+
import { makeDecisionsCallCost } from './cost.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pinned Jev slug — the alias `~typesafe/jev-latest` drifts silently and
|
|
5
|
+
* adjudication thresholds are calibrated to a version. The alias stays
|
|
6
|
+
* usable via `config.decisionModel` for experimentation.
|
|
7
|
+
*/
|
|
8
|
+
export const JEV_DEFAULT_MODEL = 'typesafe/jev-1.13-20260917';
|
|
9
|
+
const DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
|
10
|
+
const TIMEOUT_MS = 15_000;
|
|
11
|
+
const MAX_CHOICE_OPTIONS = 255;
|
|
12
|
+
const MIN_SCORE_LEVELS = 2;
|
|
13
|
+
const MAX_SCORE_LEVELS = 10;
|
|
14
|
+
export class DecisionError extends Error {
|
|
15
|
+
kind;
|
|
16
|
+
retryable;
|
|
17
|
+
constructor(kind, message, retryable) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
this.retryable = retryable;
|
|
21
|
+
this.name = 'DecisionError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Type guards over the answer union — one `in` check per lane otherwise. */
|
|
25
|
+
export const isNoulAnswer = (a) => 'noul' in a;
|
|
26
|
+
export const isChoiceAnswer = (a) => 'choice' in a;
|
|
27
|
+
export const isScoreAnswer = (a) => 'score' in a;
|
|
28
|
+
/** Short error label for lane debug lines: DecisionError kind, else message. */
|
|
29
|
+
export function describeDecisionError(e) {
|
|
30
|
+
return e instanceof DecisionError ? e.kind : e.message;
|
|
31
|
+
}
|
|
32
|
+
/** Shared per-call batch cap for the Jev lanes (secrets, findings, triage). */
|
|
33
|
+
export const MAX_CANDIDATES = 50;
|
|
34
|
+
function isRecord(v) {
|
|
35
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
36
|
+
}
|
|
37
|
+
function validateQuestions(questions) {
|
|
38
|
+
const entries = Object.entries(questions);
|
|
39
|
+
if (entries.length === 0) {
|
|
40
|
+
throw new DecisionError('validation', 'decide: at least one question is required', false);
|
|
41
|
+
}
|
|
42
|
+
for (const [id, q] of entries) {
|
|
43
|
+
if (id === '') {
|
|
44
|
+
throw new DecisionError('validation', 'decide: question IDs must be non-empty', false);
|
|
45
|
+
}
|
|
46
|
+
if (q.type === 'choice') {
|
|
47
|
+
const n = Object.keys(q.criteria).length;
|
|
48
|
+
if (n === 0 || n > MAX_CHOICE_OPTIONS) {
|
|
49
|
+
throw new DecisionError('validation', `decide: choice question "${id}" needs 1-${MAX_CHOICE_OPTIONS} options, got ${n}`, false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (q.type === 'score') {
|
|
53
|
+
const n = q.criteria.length;
|
|
54
|
+
if (n < MIN_SCORE_LEVELS || n > MAX_SCORE_LEVELS) {
|
|
55
|
+
throw new DecisionError('validation', `decide: score question "${id}" needs ${MIN_SCORE_LEVELS}-${MAX_SCORE_LEVELS} levels, got ${n}`, false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (q.type !== 'noul') {
|
|
59
|
+
throw new DecisionError('validation', `decide: question "${id}" has unknown type`, false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function validateAnswers(questions, answers) {
|
|
64
|
+
// Per-question salvage: a missing or malformed answer is skipped, not
|
|
65
|
+
// fatal — every caller degrades open per item (undefined answer →
|
|
66
|
+
// unadjudicated), so one bad f_i must not void the whole batch.
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [id, q] of Object.entries(questions)) {
|
|
69
|
+
const a = answers[id];
|
|
70
|
+
if (!isRecord(a))
|
|
71
|
+
continue;
|
|
72
|
+
if (q.type === 'noul') {
|
|
73
|
+
if (typeof a.noul === 'number' && Number.isFinite(a.noul) && a.noul >= 0 && a.noul <= 1) {
|
|
74
|
+
out[id] = { noul: a.noul };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (q.type === 'choice') {
|
|
78
|
+
const confOk = a.confidence === undefined ||
|
|
79
|
+
(typeof a.confidence === 'number' &&
|
|
80
|
+
Number.isFinite(a.confidence) &&
|
|
81
|
+
a.confidence >= 0 &&
|
|
82
|
+
a.confidence <= 1);
|
|
83
|
+
if (typeof a.choice === 'string' && Object.hasOwn(q.criteria, a.choice) && confOk) {
|
|
84
|
+
out[id] = a;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Score answers must land inside the rubric — an out-of-range
|
|
89
|
+
// score could otherwise satisfy a low-risk routing predicate.
|
|
90
|
+
const maxScore = Array.isArray(q.criteria) ? q.criteria.length : 0;
|
|
91
|
+
if (typeof a.score === 'number' &&
|
|
92
|
+
Number.isFinite(a.score) &&
|
|
93
|
+
a.score >= 1 &&
|
|
94
|
+
a.score <= maxScore) {
|
|
95
|
+
out[id] = a;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function classifyStatus(status) {
|
|
102
|
+
if (status === 401 || status === 403) {
|
|
103
|
+
return new DecisionError('auth', `decide: HTTP ${status} — check OPENROUTER_API_KEY`, false);
|
|
104
|
+
}
|
|
105
|
+
if (status === 400 || status === 404 || status === 422) {
|
|
106
|
+
return new DecisionError('validation', `decide: HTTP ${status} — request rejected`, false);
|
|
107
|
+
}
|
|
108
|
+
if (status === 429) {
|
|
109
|
+
return new DecisionError('rate_limited', 'decide: HTTP 429 rate limited', true);
|
|
110
|
+
}
|
|
111
|
+
if (status === 503) {
|
|
112
|
+
return new DecisionError('overloaded', 'decide: HTTP 503 provider overloaded', true);
|
|
113
|
+
}
|
|
114
|
+
if (status >= 500) {
|
|
115
|
+
return new DecisionError('server_error', `decide: HTTP ${status}`, true);
|
|
116
|
+
}
|
|
117
|
+
return new DecisionError('unexpected', `decide: HTTP ${status}`, false);
|
|
118
|
+
}
|
|
119
|
+
function retryDelayMs(res) {
|
|
120
|
+
const retryAfterMs = res.headers.get('retry-after-ms');
|
|
121
|
+
if (retryAfterMs !== null) {
|
|
122
|
+
const parsed = Number(retryAfterMs);
|
|
123
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
124
|
+
return Math.min(parsed, 30_000);
|
|
125
|
+
}
|
|
126
|
+
const retryAfter = res.headers.get('retry-after');
|
|
127
|
+
if (retryAfter !== null) {
|
|
128
|
+
const parsed = Number(retryAfter);
|
|
129
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
130
|
+
return Math.min(parsed * 1000, 30_000);
|
|
131
|
+
}
|
|
132
|
+
return 1_000;
|
|
133
|
+
}
|
|
134
|
+
export class DecisionClient {
|
|
135
|
+
_apiKey;
|
|
136
|
+
_fetch;
|
|
137
|
+
_trace;
|
|
138
|
+
_timeoutMs;
|
|
139
|
+
_onCall;
|
|
140
|
+
constructor(opts) {
|
|
141
|
+
if (!opts.apiKey) {
|
|
142
|
+
throw new Error('OPENROUTER_API_KEY is required: pass a non-empty apiKey at construction');
|
|
143
|
+
}
|
|
144
|
+
this._apiKey = opts.apiKey;
|
|
145
|
+
this._fetch = opts.fetch ?? globalThis.fetch;
|
|
146
|
+
this._trace = opts.trace;
|
|
147
|
+
this._timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
148
|
+
this._onCall = opts.onCall;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Ask typed questions about `state`. Throws a typed `DecisionError` on
|
|
152
|
+
* any failure — callers catch and degrade to unadjudicated; a decision
|
|
153
|
+
* failure must never suppress findings (KTD6).
|
|
154
|
+
*/
|
|
155
|
+
async decide(opts) {
|
|
156
|
+
const model = opts.model ?? JEV_DEFAULT_MODEL;
|
|
157
|
+
validateQuestions(opts.questions);
|
|
158
|
+
const body = {
|
|
159
|
+
model,
|
|
160
|
+
state: opts.state,
|
|
161
|
+
questions: opts.questions,
|
|
162
|
+
};
|
|
163
|
+
if (this._trace)
|
|
164
|
+
body.trace = this._trace;
|
|
165
|
+
let lastErr;
|
|
166
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timeout = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
169
|
+
try {
|
|
170
|
+
const res = await this._fetch(DECISIONS_URL, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${this._apiKey}`,
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'X-Title': 'argus-reviewer',
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
const err = classifyStatus(res.status);
|
|
182
|
+
if (err.retryable && attempt === 0) {
|
|
183
|
+
const delay = retryDelayMs(res);
|
|
184
|
+
debug('decisions', `${err.kind} — retrying in ${delay}ms`);
|
|
185
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
const data = (await res.json());
|
|
191
|
+
if (!isRecord(data.answers)) {
|
|
192
|
+
throw new DecisionError('unexpected', 'decide: response has no answers object', false);
|
|
193
|
+
}
|
|
194
|
+
const answers = validateAnswers(opts.questions, data.answers);
|
|
195
|
+
const cost = makeDecisionsCallCost(data, 'decide');
|
|
196
|
+
debug('decisions', `model=${cost.model} provider=${cost.provider} tokens=${cost.tokens} costUsd=${cost.costUsd.toFixed(6)}`);
|
|
197
|
+
this._onCall?.({
|
|
198
|
+
id: data.id ?? '',
|
|
199
|
+
model: cost.model,
|
|
200
|
+
provider: cost.provider,
|
|
201
|
+
kind: 'decide',
|
|
202
|
+
costUsd: cost.costUsd,
|
|
203
|
+
tokens: cost.tokens,
|
|
204
|
+
...(this._trace ? { trace: this._trace } : {}),
|
|
205
|
+
});
|
|
206
|
+
return { answers, cost, model: cost.model };
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (e instanceof DecisionError) {
|
|
210
|
+
lastErr = e;
|
|
211
|
+
// Validation/unexpected errors never retry — they retry identically.
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
const err = e;
|
|
215
|
+
if (err.name === 'AbortError') {
|
|
216
|
+
lastErr = new DecisionError('timeout', `decide: timed out after ${this._timeoutMs}ms`, true);
|
|
217
|
+
if (attempt === 0) {
|
|
218
|
+
debug('decisions', 'timeout — retrying once');
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
lastErr = new DecisionError('unexpected', `decide: ${err.message}`, false);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw lastErr ?? new DecisionError('unexpected', 'decide: failed', false);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argus-reviewer-e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "argus-reviewer — open-source vision-model E2E testing harness. Record, replay, heal, and assert with any OpenRouter model under a hard budget cap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"prepare": "npm run build && chmod +x dist/cli.js",
|
|
43
43
|
"eval": "node evals/run.mjs",
|
|
44
44
|
"watch": "node scripts/watch.mjs",
|
|
45
|
-
"app": "electron electron"
|
|
45
|
+
"app": "electron electron",
|
|
46
|
+
"demo": "node scripts/demo.mjs"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@eslint/js": "^9.0.0",
|