@unotest/judge 0.23.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # @unotest/judge
2
2
 
3
+ ## [0.24.0] - 2026-08-26
4
+
5
+ ### Minor Changes
6
+
7
+ - Judge: rendered text, one env namespace, preamble and majority vote.
8
+
9
+ **Breaking (judge env, no aliases).** Every `JUDGE_*` variable of the
10
+ `@unotest/judge` service is now `UNOTEST_JUDGE_*`, matching the client-side
11
+ names `@unotest/web` already used. `@unotest/judge` shipped a day before
12
+ this change with no known installs outside the monorepo, so the rename lands
13
+ without a deprecation window — the window would never be cheaper. Migration:
14
+
15
+ | Old | New |
16
+ | --------------------------- | ------------------------------------------- |
17
+ | `JUDGE_PROVIDER` | `UNOTEST_JUDGE_PROVIDER` |
18
+ | `JUDGE_MODEL` | `UNOTEST_JUDGE_MODEL` |
19
+ | `JUDGE_RETRIES` | `UNOTEST_JUDGE_RETRIES` |
20
+ | `JUDGE_TIMEOUT_MS` | `UNOTEST_JUDGE_CALL_TIMEOUT_MS` |
21
+ | `JUDGE_ACCESS_TOKEN` | `UNOTEST_JUDGE_ACCESS_TOKEN` |
22
+ | `JUDGE_CLAUDE_BIN` | `UNOTEST_JUDGE_CLAUDE_BIN` |
23
+ | `JUDGE_HOST` / `JUDGE_PORT` | `UNOTEST_JUDGE_HOST` / `UNOTEST_JUDGE_PORT` |
24
+ | `JUDGE_TOKEN` | `UNOTEST_JUDGE_TOKEN` |
25
+
26
+ Two names were deliberately not a straight prefixing. The service's per-call
27
+ budget became `UNOTEST_JUDGE_CALL_TIMEOUT_MS` because `UNOTEST_JUDGE_TIMEOUT_MS`
28
+ was already taken by the client's whole-request budget — same family, different
29
+ question. And the service's bearer token merged into the client's
30
+ `UNOTEST_JUDGE_TOKEN`: it is one secret, and in a shared overlay the two ends
31
+ have to carry the same value anyway.
32
+
33
+ **Behavioral fix: rendered text.** `assertJudge` and the DSL query
34
+ `getInnerText(loc)` now read `innerText`, not `textContent`. Both promised
35
+ rendered text and delivered the raw source-order concatenation: line breaks
36
+ gone, `display:none` subtrees and `<script>` contents folded in. A rubric
37
+ about structure ("the answer is three bullets") was unjudgeable, and
38
+ `getInnerText` returned text no user can see. Scenarios that relied on
39
+ hidden text reaching these two functions will now see it excluded.
40
+
41
+ **Vertex fixes.** `google-auth-library` moved from optional peer to a regular
42
+ dependency: as a peer it did not resolve under `npx @unotest/judge` at all,
43
+ which broke the advertised keyless path for anyone who never installs the
44
+ package locally. It is still loaded lazily, so non-vertex runs pay nothing.
45
+ Expired Application Default Credentials now produce
46
+ "run `gcloud auth application-default login`" instead of a raw Google OAuth
47
+ blob, read off the structured response body rather than message text.
48
+
49
+ **New: `UNOTEST_JUDGE_PREAMBLE`** (web side) — text prepended to every rubric
50
+ in the project, for standing context ("the app under test is a support bot,
51
+ Russian UI") that would otherwise be copy-pasted into each rubric. It applies
52
+ in both local and remote mode; a remote judge stays project-agnostic.
53
+
54
+ **New: `UNOTEST_JUDGE_VOTE=N`** (service side, odd N, default 1 = off) — N
55
+ independent calls decided by majority. It replaces `UNOTEST_JUDGE_RETRIES`
56
+ rather than stacking with it: re-asking is a deliberate bias toward `pass`,
57
+ a vote is deliberately symmetric, and running both would quietly restore the
58
+ bias the vote was chosen to remove. Ballots run concurrently, so a vote costs
59
+ N provider calls but roughly one call of wall-clock.
60
+
61
+ **Wire protocol.** New error code `config` on the judge's error responses:
62
+ the service's own setup is broken (bad env, expired credentials) and the
63
+ message carries the fix. `@unotest/web` surfaces it as a `ConfigError`
64
+ instead of burying it as a generic "judge unavailable" fault.
65
+
66
+ ### Patch Changes
67
+
68
+ - Updated dependencies
69
+ - @unotest/protocol@0.24.0
70
+
3
71
  ## [0.23.0] - 2026-08-26
4
72
 
5
73
  ### Minor Changes
package/README.md CHANGED
@@ -15,7 +15,7 @@ output.
15
15
  ## Run
16
16
 
17
17
  ```
18
- JUDGE_PROVIDER=vertex \
18
+ UNOTEST_JUDGE_PROVIDER=vertex \
19
19
  GOOGLE_CLOUD_PROJECT=my-project \
20
20
  GOOGLE_CLOUD_LOCATION=europe-west1 \
21
21
  npx @unotest/judge
@@ -24,19 +24,24 @@ npx @unotest/judge
24
24
  The service listens on `127.0.0.1:8790` by default. Point `@unotest/web`
25
25
  at it with `UNOTEST_JUDGE_MODE=remote` + `UNOTEST_JUDGE_URL=http://127.0.0.1:8790`,
26
26
  or skip the HTTP hop entirely with `UNOTEST_JUDGE_MODE=local` (in-process,
27
- requires `@unotest/judge` installed in the project).
27
+ requires `@unotest/judge` installed in the project — a project that runs
28
+ unotest through `npx`, with nothing installed locally, can only use
29
+ `remote`).
28
30
 
29
31
  ## Providers
30
32
 
31
33
  - **`vertex`** — Google Vertex AI via Application Default Credentials.
32
34
  No API keys: auth comes from ADC (`gcloud auth application-default login`,
33
35
  workload identity, or `GOOGLE_APPLICATION_CREDENTIALS`). Requires the
34
- optional peer `google-auth-library` (or a static `JUDGE_ACCESS_TOKEN`).
35
- Temperature is pinned to 0.
36
+ `google-auth-library` (a dependency of this package, loaded only when
37
+ this provider runs) or a static `UNOTEST_JUDGE_ACCESS_TOKEN` from
38
+ `gcloud auth print-access-token`. Temperature is pinned to 0.
36
39
  - **`claude`** — the local Claude Code CLI (`claude -p`), spawned per
37
40
  judgement without shell interpretation. Auth comes from your Claude Code
38
- session, so a subscription works with no API key. `JUDGE_MODEL` is passed
39
- as `--model` (aliases like `sonnet` work); unset uses the CLI's default.
41
+ session, so a subscription works with no API key this is the provider
42
+ to reach for when no cloud credentials are at hand. `UNOTEST_JUDGE_MODEL`
43
+ is passed as `--model` (aliases like `sonnet` work); unset uses the CLI's
44
+ default.
40
45
  - **`gemini`** — the Gemini API with `GEMINI_API_KEY`. Same wire format as
41
46
  `vertex`, temperature pinned to 0.
42
47
  - **`openai`** — OpenAI chat completions with `OPENAI_API_KEY`
@@ -57,24 +62,27 @@ zero provider SDKs.
57
62
 
58
63
  | Variable | Meaning | Default |
59
64
  | --- | --- | --- |
60
- | `JUDGE_PROVIDER` | `fake` \| `vertex` \| `claude` \| `gemini` \| `openai` \| `anthropic` | required |
61
- | `JUDGE_MODEL` | model id | vertex/gemini `gemini-2.5-flash`, openai `gpt-5-mini`, anthropic `claude-haiku-4-5`, claude: CLI default |
62
- | `JUDGE_RETRIES` | extra provider calls on `fail`; first `pass` wins | `1` |
63
- | `JUDGE_TIMEOUT_MS` | per-call budget, ms | `30000` (`120000` for claude) |
65
+ | `UNOTEST_JUDGE_PROVIDER` | `fake` \| `vertex` \| `claude` \| `gemini` \| `openai` \| `anthropic` | required |
66
+ | `UNOTEST_JUDGE_MODEL` | model id | vertex/gemini `gemini-2.5-flash`, openai `gpt-5-mini`, anthropic `claude-haiku-4-5`, claude: CLI default |
67
+ | `UNOTEST_JUDGE_RETRIES` | extra provider calls on `fail`; first `pass` wins | `1` |
68
+ | `UNOTEST_JUDGE_VOTE` | odd N; N independent calls, the majority decides. Replaces retries rather than stacking with them | `1` (off) |
69
+ | `UNOTEST_JUDGE_CALL_TIMEOUT_MS` | budget for ONE provider call, ms | `30000` (`120000` for claude) |
64
70
  | `GOOGLE_CLOUD_PROJECT` | vertex: ADC project | required for vertex |
65
71
  | `GOOGLE_CLOUD_LOCATION` | vertex: ADC location | required for vertex |
66
- | `JUDGE_ACCESS_TOKEN` | vertex: static bearer override (skips ADC) | — |
72
+ | `UNOTEST_JUDGE_ACCESS_TOKEN` | vertex: static bearer override (skips ADC) | — |
67
73
  | `GEMINI_API_KEY` | gemini: API key | required for gemini |
68
74
  | `OPENAI_API_KEY` | openai: API key | required for openai |
69
75
  | `ANTHROPIC_API_KEY` | anthropic: API key | required for anthropic |
70
- | `JUDGE_CLAUDE_BIN` | claude: binary override | `claude` |
71
- | `JUDGE_HOST` / `JUDGE_PORT` | bind address | `127.0.0.1` / `8790` |
72
- | `JUDGE_TOKEN` | bearer token required on every `/judge` call | off |
76
+ | `UNOTEST_JUDGE_CLAUDE_BIN` | claude: binary override | `claude` |
77
+ | `UNOTEST_JUDGE_HOST` / `UNOTEST_JUDGE_PORT` | bind address | `127.0.0.1` / `8790` |
78
+ | `UNOTEST_JUDGE_TOKEN` | bearer token required on every `/judge` call. `@unotest/web` reads the SAME name to send it | off |
73
79
 
74
80
  ## HTTP API
75
81
 
76
82
  - `POST /judge` — body `{"rubric": "...", "text": "..."}` → a
77
- `JudgeVerdict` (above). Errors: `{"error": "...", "code": "unauthorized" | "bad-request" | "provider-error" | "internal"}`.
83
+ `JudgeVerdict` (above). Errors: `{"error": "...", "code": "unauthorized" | "bad-request" | "provider-error" | "config" | "internal"}`.
84
+ `config` means the operator must fix this service's setup (bad env,
85
+ expired ADC) and the message says what to run.
78
86
  - `GET /health` — `{"ok": true}`.
79
87
 
80
88
  Wire types are shared through `@unotest/protocol` (`JudgeRequest`,
@@ -33,7 +33,7 @@ var FakeJudgeProvider = class {
33
33
  if (constraints.length === 0) {
34
34
  return {
35
35
  pass: false,
36
- reasoning: 'fake provider: rubric has no parseable constraints \u2014 use lines like "must contain: <substring>" / "must not contain: <substring>", or switch to a real provider (JUDGE_PROVIDER=vertex|claude|gemini|openai|anthropic)',
36
+ reasoning: 'fake provider: rubric has no parseable constraints \u2014 use lines like "must contain: <substring>" / "must not contain: <substring>", or switch to a real provider (UNOTEST_JUDGE_PROVIDER=vertex|claude|gemini|openai|anthropic)',
37
37
  model: FAKE_MODEL_ID
38
38
  };
39
39
  }
@@ -89,6 +89,55 @@ function parseVerdictReply(text, model) {
89
89
  };
90
90
  }
91
91
 
92
+ // src/providers/adc-auth.ts
93
+ async function buildAdcTokenSource() {
94
+ const specifier = "google-auth-library";
95
+ let mod;
96
+ try {
97
+ mod = await import(specifier);
98
+ } catch (e) {
99
+ throw new JudgeConfigError(
100
+ `Vertex provider could not load "google-auth-library" (a dependency of this package \u2014 a broken install?): ${e instanceof Error ? e.message : String(e)}. Workaround: pass a token via UNOTEST_JUDGE_ACCESS_TOKEN (\`gcloud auth print-access-token\`)`
101
+ );
102
+ }
103
+ const auth = new mod.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] });
104
+ return async () => {
105
+ let token;
106
+ try {
107
+ token = await auth.getAccessToken();
108
+ } catch (e) {
109
+ throw describeAdcFailure(e);
110
+ }
111
+ if (!token) {
112
+ throw new JudgeConfigError(
113
+ "ADC produced no access token \u2014 run `gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS"
114
+ );
115
+ }
116
+ return token;
117
+ };
118
+ }
119
+ var REAUTH_CODES = /* @__PURE__ */ new Set(["invalid_grant", "invalid_rapt"]);
120
+ function describeAdcFailure(e) {
121
+ const body = oauthErrorBody(e);
122
+ if (body && (REAUTH_CODES.has(body.error) || REAUTH_CODES.has(body.error_subtype ?? ""))) {
123
+ return new JudgeConfigError(
124
+ `Application Default Credentials need re-authentication (${body.error}${body.error_subtype ? `/${body.error_subtype}` : ""}) \u2014 run \`gcloud auth application-default login\``,
125
+ { oauthError: body.error }
126
+ );
127
+ }
128
+ const detail = body?.error_description ?? body?.error ?? (e instanceof Error ? e.message : String(e));
129
+ return new JudgeConfigError(
130
+ `Application Default Credentials failed: ${detail} \u2014 check GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION and \`gcloud auth application-default login\``
131
+ );
132
+ }
133
+ function oauthErrorBody(e) {
134
+ if (typeof e !== "object" || e === null) return void 0;
135
+ const data = e.response?.data;
136
+ if (typeof data !== "object" || data === null) return void 0;
137
+ const body = data;
138
+ return typeof body.error === "string" ? body : void 0;
139
+ }
140
+
92
141
  // src/providers/generate-content.ts
93
142
  function generateContentBody(request) {
94
143
  return {
@@ -175,27 +224,6 @@ var VertexJudgeProvider = class {
175
224
  return this.tokenSource();
176
225
  }
177
226
  };
178
- async function buildAdcTokenSource() {
179
- const specifier = "google-auth-library";
180
- let mod;
181
- try {
182
- mod = await import(specifier);
183
- } catch {
184
- throw new JudgeConfigError(
185
- 'Vertex provider needs Application Default Credentials \u2014 install the optional peer "google-auth-library" (`npm i google-auth-library`), or pass a token via JUDGE_ACCESS_TOKEN'
186
- );
187
- }
188
- const auth = new mod.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] });
189
- return async () => {
190
- const token = await auth.getAccessToken();
191
- if (!token) {
192
- throw new JudgeConfigError(
193
- "ADC produced no access token \u2014 run `gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS"
194
- );
195
- }
196
- return token;
197
- };
198
- }
199
227
 
200
228
  // src/providers/gemini.ts
201
229
  var GeminiJudgeProvider = class {
@@ -356,7 +384,7 @@ function toTypedError(e, bin, timeoutMs) {
356
384
  const err = e;
357
385
  if (err.code === "ENOENT") {
358
386
  return new JudgeConfigError(
359
- `Claude Code CLI not found ("${bin}") \u2014 install it (https://claude.com/claude-code) or point JUDGE_CLAUDE_BIN at the binary`,
387
+ `Claude Code CLI not found ("${bin}") \u2014 install it (https://claude.com/claude-code) or point UNOTEST_JUDGE_CLAUDE_BIN at the binary`,
360
388
  { bin }
361
389
  );
362
390
  }
@@ -400,8 +428,23 @@ async function judgeWithRetries(provider, request, retries) {
400
428
  }
401
429
  return last;
402
430
  }
431
+ async function judgeWithVote(provider, request, votes) {
432
+ const ballots = await Promise.all(
433
+ Array.from({ length: votes }, () => provider.judgeOnce(request))
434
+ );
435
+ const passes = ballots.filter((b) => b.pass);
436
+ const winners = passes.length * 2 > votes ? passes : ballots.filter((b) => !b.pass);
437
+ const winner = winners[0];
438
+ return {
439
+ verdict: winner.pass ? "pass" : "fail",
440
+ reasoning: `majority ${winners.length}/${votes}: ${winner.reasoning}`,
441
+ model: winner.model,
442
+ attempts: votes
443
+ };
444
+ }
403
445
 
404
446
  // src/env.ts
447
+ import { JUDGE_ENV } from "@unotest/protocol";
405
448
  var PROVIDER_KINDS = [
406
449
  "fake",
407
450
  "vertex",
@@ -436,57 +479,66 @@ function intEnv(env, name, fallback, min, max) {
436
479
  return n;
437
480
  }
438
481
  function resolveJudgeServiceEnv(env) {
439
- const provider = env.JUDGE_PROVIDER;
482
+ const provider = env[JUDGE_ENV.provider];
440
483
  if (!provider || !PROVIDER_KINDS.includes(provider)) {
441
484
  const list = PROVIDER_KINDS.map((k) => `"${k}"`).join(" | ");
442
485
  throw new JudgeConfigError(
443
- provider === void 0 || provider === "" ? `JUDGE_PROVIDER is not set \u2014 one of ${list} ("fake" is deterministic and CI-safe)` : `JUDGE_PROVIDER must be one of ${list}, got "${provider}"`
486
+ provider === void 0 || provider === "" ? `${JUDGE_ENV.provider} is not set \u2014 one of ${list} ("fake" is deterministic and CI-safe)` : `${JUDGE_ENV.provider} must be one of ${list}, got "${provider}"`
487
+ );
488
+ }
489
+ const votes = intEnv(env, JUDGE_ENV.vote, 1, 1, 9);
490
+ if (votes % 2 === 0) {
491
+ throw new JudgeConfigError(
492
+ `${JUDGE_ENV.vote} must be odd (1, 3, 5, 7, 9) \u2014 an even vote can tie, and a tie has no verdict; got "${votes}"`
444
493
  );
445
494
  }
446
495
  const resolved = {
447
496
  provider,
448
- retries: intEnv(env, "JUDGE_RETRIES", DEFAULT_RETRIES, 0, 10),
497
+ votes,
498
+ retries: intEnv(env, JUDGE_ENV.retries, DEFAULT_RETRIES, 0, 10),
449
499
  timeoutMs: intEnv(
450
500
  env,
451
- "JUDGE_TIMEOUT_MS",
501
+ JUDGE_ENV.callTimeoutMs,
452
502
  provider === "claude" ? DEFAULT_CLAUDE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS,
453
503
  1e3,
454
504
  6e5
455
505
  )
456
506
  };
457
- const model = env.JUDGE_MODEL || DEFAULT_MODEL[provider];
507
+ const model = env[JUDGE_ENV.model] || DEFAULT_MODEL[provider];
458
508
  if (model) resolved.model = model;
459
509
  if (provider === "vertex") {
460
510
  const project = env.GOOGLE_CLOUD_PROJECT;
461
511
  const location = env.GOOGLE_CLOUD_LOCATION;
462
512
  if (!project || !location) {
463
513
  throw new JudgeConfigError(
464
- "JUDGE_PROVIDER=vertex requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (ADC auth, no API keys)"
514
+ `${JUDGE_ENV.provider}=vertex requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (ADC auth, no API keys)`
465
515
  );
466
516
  }
467
517
  resolved.project = project;
468
518
  resolved.location = location;
469
- if (env.JUDGE_ACCESS_TOKEN) resolved.accessToken = env.JUDGE_ACCESS_TOKEN;
519
+ const accessToken = env[JUDGE_ENV.accessToken];
520
+ if (accessToken) resolved.accessToken = accessToken;
470
521
  }
471
522
  const keyEnvName = API_KEY_ENV[provider];
472
523
  if (keyEnvName) {
473
524
  const apiKey = env[keyEnvName];
474
525
  if (!apiKey) {
475
- throw new JudgeConfigError(`JUDGE_PROVIDER=${provider} requires ${keyEnvName}`);
526
+ throw new JudgeConfigError(`${JUDGE_ENV.provider}=${provider} requires ${keyEnvName}`);
476
527
  }
477
528
  resolved.apiKey = apiKey;
478
529
  }
479
530
  if (provider === "claude") {
480
- resolved.claudeBin = env.JUDGE_CLAUDE_BIN || "claude";
531
+ resolved.claudeBin = env[JUDGE_ENV.claudeBin] || "claude";
481
532
  }
482
533
  return resolved;
483
534
  }
484
535
  function resolveJudgeServerEnv(env) {
485
536
  const resolved = {
486
- host: env.JUDGE_HOST || "127.0.0.1",
487
- port: intEnv(env, "JUDGE_PORT", 8790, 1, 65535)
537
+ host: env[JUDGE_ENV.host] || "127.0.0.1",
538
+ port: intEnv(env, JUDGE_ENV.port, 8790, 1, 65535)
488
539
  };
489
- if (env.JUDGE_TOKEN) resolved.token = env.JUDGE_TOKEN;
540
+ const token = env[JUDGE_ENV.token];
541
+ if (token) resolved.token = token;
490
542
  return resolved;
491
543
  }
492
544
 
@@ -532,7 +584,7 @@ function buildProvider(env) {
532
584
  function createJudgeService(env, provider) {
533
585
  const p = provider ?? buildProvider(env);
534
586
  return {
535
- judge: (request) => judgeWithRetries(p, request, env.retries)
587
+ judge: (request) => env.votes > 1 ? judgeWithVote(p, request, env.votes) : judgeWithRetries(p, request, env.retries)
536
588
  };
537
589
  }
538
590
  function createJudgeServiceFromEnv(env) {
@@ -541,7 +593,10 @@ function createJudgeServiceFromEnv(env) {
541
593
 
542
594
  // src/server.ts
543
595
  import { createServer } from "http";
544
- import { JUDGE_ROUTES } from "@unotest/protocol";
596
+ import {
597
+ JUDGE_ENV as JUDGE_ENV2,
598
+ JUDGE_ROUTES
599
+ } from "@unotest/protocol";
545
600
  var MAX_BODY_BYTES = 1024 * 1024;
546
601
  function startJudgeServer(service, env) {
547
602
  const server = createServer((req, res) => {
@@ -571,7 +626,7 @@ async function route(service, env, req, res) {
571
626
  return;
572
627
  }
573
628
  if (env.token && req.headers.authorization !== `Bearer ${env.token}`) {
574
- sendError(res, 401, "missing or wrong bearer token (JUDGE_TOKEN)", "unauthorized");
629
+ sendError(res, 401, `missing or wrong bearer token (${JUDGE_ENV2.token})`, "unauthorized");
575
630
  return;
576
631
  }
577
632
  let body;
@@ -587,7 +642,7 @@ async function route(service, env, req, res) {
587
642
  if (e instanceof JudgeProviderError) {
588
643
  sendError(res, 502, e.message, "provider-error");
589
644
  } else if (e instanceof JudgeConfigError) {
590
- sendError(res, 500, e.message, "internal");
645
+ sendError(res, 500, e.message, "config");
591
646
  } else {
592
647
  sendError(res, 500, e instanceof Error ? e.message : String(e), "internal");
593
648
  }
@@ -657,4 +712,4 @@ export {
657
712
  createJudgeServiceFromEnv,
658
713
  startJudgeServer
659
714
  };
660
- //# sourceMappingURL=chunk-4VUHPR26.js.map
715
+ //# sourceMappingURL=chunk-74FL5RWE.js.map
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  resolveJudgeServerEnv,
4
4
  resolveJudgeServiceEnv,
5
5
  startJudgeServer
6
- } from "./chunk-4VUHPR26.js";
6
+ } from "./chunk-74FL5RWE.js";
7
7
 
8
8
  // src/cli.ts
9
9
  async function main(argv = process.argv.slice(2)) {
@@ -39,23 +39,26 @@ function printHelp() {
39
39
  "Usage: npx @unotest/judge [--help] [--version]",
40
40
  "",
41
41
  "Env:",
42
- " JUDGE_PROVIDER fake (deterministic, CI-safe) | vertex (ADC) |",
43
- " claude (Claude Code CLI, subscription auth) |",
44
- " gemini | openai | anthropic (API keys)",
45
- " JUDGE_MODEL model id; defaults per provider:",
46
- " vertex/gemini gemini-2.5-flash, openai gpt-5-mini,",
47
- " anthropic claude-haiku-4-5, claude: CLI default",
48
- " JUDGE_RETRIES extra provider calls on fail (default 1)",
49
- " JUDGE_TIMEOUT_MS per-call budget, ms (default 30000; claude 120000)",
50
- " GOOGLE_CLOUD_PROJECT vertex: ADC project",
51
- " GOOGLE_CLOUD_LOCATION vertex: ADC location",
52
- " JUDGE_ACCESS_TOKEN vertex: static bearer override (skips ADC)",
53
- " GEMINI_API_KEY gemini: API key",
54
- " OPENAI_API_KEY openai: API key",
55
- " ANTHROPIC_API_KEY anthropic: API key",
56
- " JUDGE_CLAUDE_BIN claude: binary override (default claude)",
57
- " JUDGE_HOST / JUDGE_PORT bind address (default 127.0.0.1:8790)",
58
- " JUDGE_TOKEN optional bearer required on every /judge call"
42
+ " UNOTEST_JUDGE_PROVIDER fake (deterministic, CI-safe) | vertex (ADC) |",
43
+ " claude (Claude Code CLI, subscription auth) |",
44
+ " gemini | openai | anthropic (API keys)",
45
+ " UNOTEST_JUDGE_MODEL model id; defaults per provider:",
46
+ " vertex/gemini gemini-2.5-flash,",
47
+ " openai gpt-5-mini, anthropic claude-haiku-4-5,",
48
+ " claude: CLI default",
49
+ " UNOTEST_JUDGE_RETRIES extra provider calls on fail (default 1)",
50
+ " UNOTEST_JUDGE_VOTE N-call majority vote (default 1 = off)",
51
+ " UNOTEST_JUDGE_CALL_TIMEOUT_MS per-call budget, ms (default 30000;",
52
+ " claude 120000)",
53
+ " GOOGLE_CLOUD_PROJECT vertex: ADC project",
54
+ " GOOGLE_CLOUD_LOCATION vertex: ADC location",
55
+ " UNOTEST_JUDGE_ACCESS_TOKEN vertex: static bearer override (skips ADC)",
56
+ " GEMINI_API_KEY gemini: API key",
57
+ " OPENAI_API_KEY openai: API key",
58
+ " ANTHROPIC_API_KEY anthropic: API key",
59
+ " UNOTEST_JUDGE_CLAUDE_BIN claude: binary override (default claude)",
60
+ " UNOTEST_JUDGE_HOST / _PORT bind address (default 127.0.0.1:8790)",
61
+ " UNOTEST_JUDGE_TOKEN optional bearer required on every /judge call"
59
62
  ].join("\n")
60
63
  );
61
64
  }
package/dist/index.d.ts CHANGED
@@ -40,7 +40,7 @@ interface VertexProviderOptions {
40
40
  project: string;
41
41
  location: string;
42
42
  model: string;
43
- /** Static bearer token override (JUDGE_ACCESS_TOKEN). Absent → ADC. */
43
+ /** Static bearer token override (UNOTEST_JUDGE_ACCESS_TOKEN). Absent → ADC. */
44
44
  accessToken?: string;
45
45
  /** Per-call wall-clock budget, ms. */
46
46
  timeoutMs: number;
@@ -110,7 +110,7 @@ type ExecFileFn = (file: string, args: string[], opts: {
110
110
  maxBuffer: number;
111
111
  }) => Promise<ExecFileResult>;
112
112
  interface ClaudeCliProviderOptions {
113
- /** Binary to spawn (JUDGE_CLAUDE_BIN, default "claude"). */
113
+ /** Binary to spawn (UNOTEST_JUDGE_CLAUDE_BIN, default "claude"). */
114
114
  bin: string;
115
115
  /** Passed as `--model` only when set — otherwise the CLI's default. */
116
116
  model?: string;
@@ -137,11 +137,14 @@ declare function judgeWithRetries(provider: JudgeProvider, request: JudgeRequest
137
137
  type JudgeProviderKind = "fake" | "vertex" | "claude" | "gemini" | "openai" | "anthropic";
138
138
  interface JudgeServiceEnv {
139
139
  provider: JudgeProviderKind;
140
- /** Model id. Absent only for `claude` without JUDGE_MODEL (CLI default)
140
+ /** Model id. Absent only for `claude` without UNOTEST_JUDGE_MODEL (CLI default)
141
141
  * and for `fake` (no model behind it). */
142
142
  model?: string;
143
- /** Extra provider calls allowed on `fail` (first `pass` wins). */
143
+ /** Extra provider calls allowed on `fail` (first `pass` wins). Ignored
144
+ * when `votes` > 1 — see policy.ts. */
144
145
  retries: number;
146
+ /** Independent calls whose majority decides the verdict. 1 = off. */
147
+ votes: number;
145
148
  /** Per-provider-call wall-clock budget, ms. */
146
149
  timeoutMs: number;
147
150
  /** Vertex ADC target. */
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  resolveJudgeServerEnv,
20
20
  resolveJudgeServiceEnv,
21
21
  startJudgeServer
22
- } from "./chunk-4VUHPR26.js";
22
+ } from "./chunk-74FL5RWE.js";
23
23
 
24
24
  // src/index.ts
25
25
  import { JUDGE_ROUTES } from "@unotest/protocol";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/judge",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "LLM-judge service for the @unotest ecosystem: judges free-form text (chat replies, generated content) against a natural-language rubric and returns a structured pass/fail verdict with reasoning. Runs as a small HTTP service (`npx @unotest/judge`) or in-process. Providers: deterministic `fake` (CI-safe), Google Vertex AI via ADC, the local Claude Code CLI (subscription auth, no API key), and Gemini / OpenAI / Anthropic APIs via keys — all raw HTTP or a local process, zero provider SDKs. Backs the `assertJudge` DSL assertion of @unotest/web.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -32,15 +32,8 @@
32
32
  "README.md"
33
33
  ],
34
34
  "dependencies": {
35
- "@unotest/protocol": "^0.23.0"
36
- },
37
- "peerDependencies": {
38
- "google-auth-library": "^9.0.0 || ^10.0.0"
39
- },
40
- "peerDependenciesMeta": {
41
- "google-auth-library": {
42
- "optional": true
43
- }
35
+ "google-auth-library": "^10.0.0",
36
+ "@unotest/protocol": "^0.24.0"
44
37
  },
45
38
  "devDependencies": {
46
39
  "@types/node": "^22.10.0",