@unotest/judge 0.23.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 +51 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/bin/unotest-judge.js +11 -0
- package/dist/chunk-4VUHPR26.js +660 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +65 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.js +49 -0
- package/package.json +51 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @unotest/judge
|
|
2
|
+
|
|
3
|
+
## [0.23.0] - 2026-08-26
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- LLM-judge assertion for free-form text: `assertJudge(locator, rubric)`.
|
|
8
|
+
|
|
9
|
+
- New package `@unotest/judge` — the judge service (`npx @unotest/judge`):
|
|
10
|
+
judges text against a natural-language rubric and returns a structured
|
|
11
|
+
`{verdict, reasoning, model, attempts}`. Providers: `vertex` (Google
|
|
12
|
+
Vertex AI via ADC, no API keys; temperature pinned to 0) and `fake`
|
|
13
|
+
(deterministic, CI-safe). Non-determinism policy lives in the service:
|
|
14
|
+
`JUDGE_RETRIES` re-asks on `fail`, first `pass` wins.
|
|
15
|
+
- `@unotest/web`: new DSL assertion `assertJudge(locator, rubric)` — the
|
|
16
|
+
element's rendered text + the rubric go to the judge; a `fail` verdict
|
|
17
|
+
fails the step with the judge's reasoning. Wired via env so the config
|
|
18
|
+
sits in the `--env` overlay: `UNOTEST_JUDGE_MODE=off|local|remote`,
|
|
19
|
+
`UNOTEST_JUDGE_URL`, `UNOTEST_JUDGE_TOKEN`, `UNOTEST_JUDGE_TIMEOUT_MS`.
|
|
20
|
+
Every verdict (pass and fail) is recorded into the run's `steps.jsonl`.
|
|
21
|
+
Also documented: `shell()` runs with cwd = the project root
|
|
22
|
+
(`sandbox.shellCwd` to override) and returns `{stdout, stderr, code}`.
|
|
23
|
+
- `@unotest/protocol`: judge wire types (`JudgeRequest`, `JudgeVerdict`,
|
|
24
|
+
`JUDGE_ROUTES`) and the new `judge:verdict` run-artifact event.
|
|
25
|
+
|
|
26
|
+
- Judge provider matrix: four new `JUDGE_PROVIDER` backends.
|
|
27
|
+
|
|
28
|
+
- `claude` — spawns the local Claude Code CLI (`claude -p`, no shell
|
|
29
|
+
interpretation): auth comes from the Claude Code session, so a
|
|
30
|
+
subscription works with no API key. `JUDGE_MODEL` is passed as
|
|
31
|
+
`--model` (aliases like `sonnet` work); `JUDGE_CLAUDE_BIN` overrides
|
|
32
|
+
the binary; default `JUDGE_TIMEOUT_MS` is 120000 for this provider.
|
|
33
|
+
- `gemini` — the Gemini API with `GEMINI_API_KEY` (same wire format as
|
|
34
|
+
`vertex`, temperature pinned to 0; default model `gemini-2.5-flash`).
|
|
35
|
+
- `openai` — OpenAI chat completions with `OPENAI_API_KEY`
|
|
36
|
+
(`response_format: json_object`; default model `gpt-5-mini`).
|
|
37
|
+
- `anthropic` — the Anthropic API (`/v1/messages`) with
|
|
38
|
+
`ANTHROPIC_API_KEY` (default model `claude-haiku-4-5`).
|
|
39
|
+
|
|
40
|
+
`openai` and `anthropic` deliberately send no sampling params — current
|
|
41
|
+
reasoning models reject `temperature`; determinism relies on the strict
|
|
42
|
+
JSON verdict prompt plus the `JUDGE_RETRIES` policy. Still zero provider
|
|
43
|
+
SDKs: everything is raw HTTP or a local process.
|
|
44
|
+
|
|
45
|
+
`@unotest/web`: docs only — provider matrix in the `assertJudge`
|
|
46
|
+
reference and the `.env.example` judge block.
|
|
47
|
+
|
|
48
|
+
### Patch Changes
|
|
49
|
+
|
|
50
|
+
- Updated dependencies
|
|
51
|
+
- @unotest/protocol@0.23.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Volkov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @unotest/judge
|
|
2
|
+
|
|
3
|
+
LLM-judge service for the [@unotest](https://www.npmjs.com/package/@unotest/web)
|
|
4
|
+
ecosystem. It judges free-form text (a chat reply, generated content)
|
|
5
|
+
against a natural-language rubric and returns a structured verdict:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{ "verdict": "fail", "reasoning": "the reply promises escalation", "model": "gemini-2.5-flash", "attempts": 2 }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It backs the `assertJudge(locator, rubric)` assertion of `@unotest/web` —
|
|
12
|
+
semantic checks where substring/regex assertions are too brittle for LLM
|
|
13
|
+
output.
|
|
14
|
+
|
|
15
|
+
## Run
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
JUDGE_PROVIDER=vertex \
|
|
19
|
+
GOOGLE_CLOUD_PROJECT=my-project \
|
|
20
|
+
GOOGLE_CLOUD_LOCATION=europe-west1 \
|
|
21
|
+
npx @unotest/judge
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The service listens on `127.0.0.1:8790` by default. Point `@unotest/web`
|
|
25
|
+
at it with `UNOTEST_JUDGE_MODE=remote` + `UNOTEST_JUDGE_URL=http://127.0.0.1:8790`,
|
|
26
|
+
or skip the HTTP hop entirely with `UNOTEST_JUDGE_MODE=local` (in-process,
|
|
27
|
+
requires `@unotest/judge` installed in the project).
|
|
28
|
+
|
|
29
|
+
## Providers
|
|
30
|
+
|
|
31
|
+
- **`vertex`** — Google Vertex AI via Application Default Credentials.
|
|
32
|
+
No API keys: auth comes from ADC (`gcloud auth application-default login`,
|
|
33
|
+
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
|
+
- **`claude`** — the local Claude Code CLI (`claude -p`), spawned per
|
|
37
|
+
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.
|
|
40
|
+
- **`gemini`** — the Gemini API with `GEMINI_API_KEY`. Same wire format as
|
|
41
|
+
`vertex`, temperature pinned to 0.
|
|
42
|
+
- **`openai`** — OpenAI chat completions with `OPENAI_API_KEY`
|
|
43
|
+
(`response_format: json_object`). No sampling params: reasoning models
|
|
44
|
+
reject `temperature`, so determinism relies on the strict verdict prompt
|
|
45
|
+
plus the retry policy.
|
|
46
|
+
- **`anthropic`** — the Anthropic API (`/v1/messages`) with
|
|
47
|
+
`ANTHROPIC_API_KEY`. No sampling params, same reasoning as `openai`.
|
|
48
|
+
- **`fake`** — deterministic, no model. For CI and smoke-testing the
|
|
49
|
+
wiring. The rubric is a micro-grammar, one constraint per line:
|
|
50
|
+
`must contain: <substring>` / `must not contain: <substring>`
|
|
51
|
+
(case-insensitive). Anything else fails loudly.
|
|
52
|
+
|
|
53
|
+
All model providers are raw HTTP or a local process — this package ships
|
|
54
|
+
zero provider SDKs.
|
|
55
|
+
|
|
56
|
+
## Env
|
|
57
|
+
|
|
58
|
+
| Variable | Meaning | Default |
|
|
59
|
+
| --- | --- | --- |
|
|
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) |
|
|
64
|
+
| `GOOGLE_CLOUD_PROJECT` | vertex: ADC project | required for vertex |
|
|
65
|
+
| `GOOGLE_CLOUD_LOCATION` | vertex: ADC location | required for vertex |
|
|
66
|
+
| `JUDGE_ACCESS_TOKEN` | vertex: static bearer override (skips ADC) | — |
|
|
67
|
+
| `GEMINI_API_KEY` | gemini: API key | required for gemini |
|
|
68
|
+
| `OPENAI_API_KEY` | openai: API key | required for openai |
|
|
69
|
+
| `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 |
|
|
73
|
+
|
|
74
|
+
## HTTP API
|
|
75
|
+
|
|
76
|
+
- `POST /judge` — body `{"rubric": "...", "text": "..."}` → a
|
|
77
|
+
`JudgeVerdict` (above). Errors: `{"error": "...", "code": "unauthorized" | "bad-request" | "provider-error" | "internal"}`.
|
|
78
|
+
- `GET /health` — `{"ok": true}`.
|
|
79
|
+
|
|
80
|
+
Wire types are shared through `@unotest/protocol` (`JudgeRequest`,
|
|
81
|
+
`JudgeVerdict`, `JUDGE_ROUTES`), so the service and `@unotest/web`'s
|
|
82
|
+
client cannot drift.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT © Ivan Volkov
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../dist/cli.js";
|
|
3
|
+
|
|
4
|
+
main().catch((e) => {
|
|
5
|
+
if (process.env.UNOTEST_DEBUG === "1") {
|
|
6
|
+
console.error(e);
|
|
7
|
+
} else {
|
|
8
|
+
console.error(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
9
|
+
}
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var JudgeError = class extends Error {
|
|
3
|
+
constructor(message, context = {}) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.context = context;
|
|
6
|
+
this.name = new.target.name;
|
|
7
|
+
}
|
|
8
|
+
context;
|
|
9
|
+
};
|
|
10
|
+
var JudgeConfigError = class extends JudgeError {
|
|
11
|
+
};
|
|
12
|
+
var JudgeProviderError = class extends JudgeError {
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/providers/fake.ts
|
|
16
|
+
var FAKE_MODEL_ID = "fake";
|
|
17
|
+
var CONSTRAINT_RE = /^must(?<not>\s+not)?\s+contain:\s*(?<needle>.+)$/i;
|
|
18
|
+
function parseFakeRubric(rubric) {
|
|
19
|
+
const constraints = [];
|
|
20
|
+
for (const line of rubric.split(/\r?\n/)) {
|
|
21
|
+
const m = CONSTRAINT_RE.exec(line.trim());
|
|
22
|
+
if (!m?.groups?.needle) continue;
|
|
23
|
+
constraints.push({
|
|
24
|
+
kind: m.groups.not ? "not-contains" : "contains",
|
|
25
|
+
needle: m.groups.needle.trim()
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return constraints;
|
|
29
|
+
}
|
|
30
|
+
var FakeJudgeProvider = class {
|
|
31
|
+
async judgeOnce(request) {
|
|
32
|
+
const constraints = parseFakeRubric(request.rubric);
|
|
33
|
+
if (constraints.length === 0) {
|
|
34
|
+
return {
|
|
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)',
|
|
37
|
+
model: FAKE_MODEL_ID
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const text = request.text.toLowerCase();
|
|
41
|
+
const violations = [];
|
|
42
|
+
for (const c of constraints) {
|
|
43
|
+
const hit = text.includes(c.needle.toLowerCase());
|
|
44
|
+
if (c.kind === "contains" && !hit) violations.push(`missing required "${c.needle}"`);
|
|
45
|
+
if (c.kind === "not-contains" && hit) violations.push(`contains forbidden "${c.needle}"`);
|
|
46
|
+
}
|
|
47
|
+
if (violations.length > 0) {
|
|
48
|
+
return { pass: false, reasoning: violations.join("; "), model: FAKE_MODEL_ID };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
pass: true,
|
|
52
|
+
reasoning: `all ${constraints.length} constraint(s) satisfied`,
|
|
53
|
+
model: FAKE_MODEL_ID
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/verdict.ts
|
|
59
|
+
var VERDICT_PROMPT = (rubric, text) => `You are a strict test judge. Evaluate the TEXT against the RUBRIC.
|
|
60
|
+
Reply with ONLY a raw JSON object, no markdown code fences: {"verdict": "pass" | "fail", "reasoning": "<one short sentence>"}.
|
|
61
|
+
RUBRIC:
|
|
62
|
+
${rubric}
|
|
63
|
+
|
|
64
|
+
TEXT:
|
|
65
|
+
${text}`;
|
|
66
|
+
var FENCED_JSON = /^```(?:json)?\s*\n([\s\S]*?)\n\s*```$/;
|
|
67
|
+
function parseVerdictReply(text, model) {
|
|
68
|
+
const trimmed = text.trim();
|
|
69
|
+
const raw = FENCED_JSON.exec(trimmed)?.[1] ?? trimmed;
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new JudgeProviderError(
|
|
75
|
+
`model "${model}" did not reply with the requested JSON verdict: ${text.slice(0, 200)}`,
|
|
76
|
+
{ model }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const { verdict, reasoning } = parsed;
|
|
80
|
+
if (verdict !== "pass" && verdict !== "fail") {
|
|
81
|
+
throw new JudgeProviderError(
|
|
82
|
+
`model "${model}" replied with an unknown verdict ${JSON.stringify(verdict)} (expected "pass"/"fail")`,
|
|
83
|
+
{ model }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
pass: verdict === "pass",
|
|
88
|
+
reasoning: typeof reasoning === "string" && reasoning.length > 0 ? reasoning : "(no reasoning)"
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/providers/generate-content.ts
|
|
93
|
+
function generateContentBody(request) {
|
|
94
|
+
return {
|
|
95
|
+
contents: [
|
|
96
|
+
{ role: "user", parts: [{ text: VERDICT_PROMPT(request.rubric, request.text) }] }
|
|
97
|
+
],
|
|
98
|
+
generationConfig: {
|
|
99
|
+
temperature: 0,
|
|
100
|
+
responseMimeType: "application/json"
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function extractCandidateText(body, model, backend) {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(body);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new JudgeProviderError(`${backend} reply is not JSON: ${body.slice(0, 200)}`, { model });
|
|
110
|
+
}
|
|
111
|
+
const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
112
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
113
|
+
throw new JudgeProviderError(
|
|
114
|
+
`${backend} reply carries no candidate text (blocked or empty): ${body.slice(0, 300)}`,
|
|
115
|
+
{ model }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/providers/http-json.ts
|
|
122
|
+
async function postJson(call) {
|
|
123
|
+
const { url, headers, body, timeoutMs, backend, model, fetchImpl } = call;
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await fetchImpl(url, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/json", ...headers },
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
131
|
+
});
|
|
132
|
+
} catch (e) {
|
|
133
|
+
throw new JudgeProviderError(
|
|
134
|
+
`cannot reach ${backend} at ${url} (${e instanceof Error ? e.message : String(e)})`,
|
|
135
|
+
{ url }
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new JudgeProviderError(
|
|
141
|
+
`${backend} replied ${res.status} for model "${model}": ${text.slice(0, 500)}`,
|
|
142
|
+
{ status: res.status, model }
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/providers/vertex.ts
|
|
149
|
+
var VertexJudgeProvider = class {
|
|
150
|
+
constructor(opts) {
|
|
151
|
+
this.opts = opts;
|
|
152
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
153
|
+
}
|
|
154
|
+
opts;
|
|
155
|
+
fetchImpl;
|
|
156
|
+
tokenSource;
|
|
157
|
+
async judgeOnce(request) {
|
|
158
|
+
const { project, location, model, timeoutMs } = this.opts;
|
|
159
|
+
const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`;
|
|
160
|
+
const token = await this.accessToken();
|
|
161
|
+
const body = await postJson({
|
|
162
|
+
url,
|
|
163
|
+
headers: { authorization: `Bearer ${token}` },
|
|
164
|
+
body: generateContentBody(request),
|
|
165
|
+
timeoutMs,
|
|
166
|
+
backend: "Vertex AI",
|
|
167
|
+
model,
|
|
168
|
+
fetchImpl: this.fetchImpl
|
|
169
|
+
});
|
|
170
|
+
return { ...parseVerdictReply(extractCandidateText(body, model, "Vertex AI"), model), model };
|
|
171
|
+
}
|
|
172
|
+
async accessToken() {
|
|
173
|
+
if (this.opts.accessToken) return this.opts.accessToken;
|
|
174
|
+
this.tokenSource ??= await buildAdcTokenSource();
|
|
175
|
+
return this.tokenSource();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
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
|
+
|
|
200
|
+
// src/providers/gemini.ts
|
|
201
|
+
var GeminiJudgeProvider = class {
|
|
202
|
+
constructor(opts) {
|
|
203
|
+
this.opts = opts;
|
|
204
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
205
|
+
}
|
|
206
|
+
opts;
|
|
207
|
+
fetchImpl;
|
|
208
|
+
async judgeOnce(request) {
|
|
209
|
+
const { apiKey, model, timeoutMs } = this.opts;
|
|
210
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
211
|
+
const body = await postJson({
|
|
212
|
+
url,
|
|
213
|
+
headers: { "x-goog-api-key": apiKey },
|
|
214
|
+
body: generateContentBody(request),
|
|
215
|
+
timeoutMs,
|
|
216
|
+
backend: "Gemini API",
|
|
217
|
+
model,
|
|
218
|
+
fetchImpl: this.fetchImpl
|
|
219
|
+
});
|
|
220
|
+
return { ...parseVerdictReply(extractCandidateText(body, model, "Gemini API"), model), model };
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/providers/openai.ts
|
|
225
|
+
var OpenAiJudgeProvider = class {
|
|
226
|
+
constructor(opts) {
|
|
227
|
+
this.opts = opts;
|
|
228
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
229
|
+
}
|
|
230
|
+
opts;
|
|
231
|
+
fetchImpl;
|
|
232
|
+
async judgeOnce(request) {
|
|
233
|
+
const { apiKey, model, timeoutMs } = this.opts;
|
|
234
|
+
const body = await postJson({
|
|
235
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
236
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
237
|
+
body: {
|
|
238
|
+
model,
|
|
239
|
+
messages: [{ role: "user", content: VERDICT_PROMPT(request.rubric, request.text) }],
|
|
240
|
+
response_format: { type: "json_object" }
|
|
241
|
+
},
|
|
242
|
+
timeoutMs,
|
|
243
|
+
backend: "OpenAI",
|
|
244
|
+
model,
|
|
245
|
+
fetchImpl: this.fetchImpl
|
|
246
|
+
});
|
|
247
|
+
return { ...parseVerdictReply(extractMessageContent(body, model), model), model };
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
function extractMessageContent(body, model) {
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(body);
|
|
254
|
+
} catch {
|
|
255
|
+
throw new JudgeProviderError(`OpenAI reply is not JSON: ${body.slice(0, 200)}`, { model });
|
|
256
|
+
}
|
|
257
|
+
const content = parsed.choices?.[0]?.message?.content;
|
|
258
|
+
if (typeof content !== "string" || content.length === 0) {
|
|
259
|
+
throw new JudgeProviderError(
|
|
260
|
+
`OpenAI reply carries no message content: ${body.slice(0, 300)}`,
|
|
261
|
+
{ model }
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return content;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/providers/anthropic.ts
|
|
268
|
+
var ANTHROPIC_VERSION = "2023-06-01";
|
|
269
|
+
var MAX_TOKENS = 1024;
|
|
270
|
+
var AnthropicJudgeProvider = class {
|
|
271
|
+
constructor(opts) {
|
|
272
|
+
this.opts = opts;
|
|
273
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
274
|
+
}
|
|
275
|
+
opts;
|
|
276
|
+
fetchImpl;
|
|
277
|
+
async judgeOnce(request) {
|
|
278
|
+
const { apiKey, model, timeoutMs } = this.opts;
|
|
279
|
+
const body = await postJson({
|
|
280
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
281
|
+
headers: { "x-api-key": apiKey, "anthropic-version": ANTHROPIC_VERSION },
|
|
282
|
+
body: {
|
|
283
|
+
model,
|
|
284
|
+
max_tokens: MAX_TOKENS,
|
|
285
|
+
messages: [{ role: "user", content: VERDICT_PROMPT(request.rubric, request.text) }]
|
|
286
|
+
},
|
|
287
|
+
timeoutMs,
|
|
288
|
+
backend: "Anthropic API",
|
|
289
|
+
model,
|
|
290
|
+
fetchImpl: this.fetchImpl
|
|
291
|
+
});
|
|
292
|
+
return { ...parseVerdictReply(extractTextBlock(body, model), model), model };
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
function extractTextBlock(body, model) {
|
|
296
|
+
let parsed;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(body);
|
|
299
|
+
} catch {
|
|
300
|
+
throw new JudgeProviderError(`Anthropic API reply is not JSON: ${body.slice(0, 200)}`, {
|
|
301
|
+
model
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const blocks = parsed.content;
|
|
305
|
+
const text = blocks?.find((b) => b.type === "text")?.text;
|
|
306
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
307
|
+
throw new JudgeProviderError(
|
|
308
|
+
`Anthropic API reply carries no text block (refusal or empty): ${body.slice(0, 300)}`,
|
|
309
|
+
{ model }
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return text;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/providers/claude-cli.ts
|
|
316
|
+
var MAX_STDOUT_BYTES = 10 * 1024 * 1024;
|
|
317
|
+
var ClaudeCliJudgeProvider = class {
|
|
318
|
+
constructor(opts) {
|
|
319
|
+
this.opts = opts;
|
|
320
|
+
}
|
|
321
|
+
opts;
|
|
322
|
+
async judgeOnce(request) {
|
|
323
|
+
const { bin, model, timeoutMs } = this.opts;
|
|
324
|
+
const args = [
|
|
325
|
+
"-p",
|
|
326
|
+
"--output-format",
|
|
327
|
+
"json",
|
|
328
|
+
"--strict-mcp-config",
|
|
329
|
+
...model ? ["--model", model] : [],
|
|
330
|
+
VERDICT_PROMPT(request.rubric, request.text)
|
|
331
|
+
];
|
|
332
|
+
const exec = this.opts.execImpl ?? await defaultExec();
|
|
333
|
+
let stdout;
|
|
334
|
+
try {
|
|
335
|
+
({ stdout } = await exec(bin, args, { timeout: timeoutMs, maxBuffer: MAX_STDOUT_BYTES }));
|
|
336
|
+
} catch (e) {
|
|
337
|
+
throw toTypedError(e, bin, timeoutMs);
|
|
338
|
+
}
|
|
339
|
+
const envelope = parseEnvelope(stdout, bin);
|
|
340
|
+
const modelLabel = firstModelId(envelope) ?? model ?? "claude";
|
|
341
|
+
if (envelope.is_error === true || typeof envelope.result !== "string" || !envelope.result) {
|
|
342
|
+
throw new JudgeProviderError(
|
|
343
|
+
`${bin} -p returned an error result: ${String(envelope.result ?? stdout).slice(0, 300)}`,
|
|
344
|
+
{ model: modelLabel }
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return { ...parseVerdictReply(envelope.result, modelLabel), model: modelLabel };
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
async function defaultExec() {
|
|
351
|
+
const { execFile } = await import("child_process");
|
|
352
|
+
const { promisify } = await import("util");
|
|
353
|
+
return promisify(execFile);
|
|
354
|
+
}
|
|
355
|
+
function toTypedError(e, bin, timeoutMs) {
|
|
356
|
+
const err = e;
|
|
357
|
+
if (err.code === "ENOENT") {
|
|
358
|
+
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`,
|
|
360
|
+
{ bin }
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (err.code === "EACCES") {
|
|
364
|
+
return new JudgeConfigError(`Claude Code CLI is not executable ("${bin}")`, { bin });
|
|
365
|
+
}
|
|
366
|
+
if (err.killed === true) {
|
|
367
|
+
return new JudgeProviderError(`${bin} -p timed out after ${timeoutMs}ms`, { bin, timeoutMs });
|
|
368
|
+
}
|
|
369
|
+
const stderr = typeof err.stderr === "string" && err.stderr ? err.stderr : String(err.message ?? e);
|
|
370
|
+
return new JudgeProviderError(`${bin} -p failed: ${stderr.slice(0, 500)}`, { bin });
|
|
371
|
+
}
|
|
372
|
+
function parseEnvelope(stdout, bin) {
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(stdout);
|
|
375
|
+
} catch {
|
|
376
|
+
throw new JudgeProviderError(
|
|
377
|
+
`${bin} -p did not return the JSON envelope: ${stdout.slice(0, 300)}`,
|
|
378
|
+
{ bin }
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function firstModelId(envelope) {
|
|
383
|
+
const keys = envelope.modelUsage ? Object.keys(envelope.modelUsage) : [];
|
|
384
|
+
return keys.length > 0 ? keys[0] : void 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/policy.ts
|
|
388
|
+
async function judgeWithRetries(provider, request, retries) {
|
|
389
|
+
const maxAttempts = Math.max(0, retries) + 1;
|
|
390
|
+
let last = null;
|
|
391
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
392
|
+
const single = await provider.judgeOnce(request);
|
|
393
|
+
last = {
|
|
394
|
+
verdict: single.pass ? "pass" : "fail",
|
|
395
|
+
reasoning: single.reasoning,
|
|
396
|
+
model: single.model,
|
|
397
|
+
attempts: attempt
|
|
398
|
+
};
|
|
399
|
+
if (single.pass) return last;
|
|
400
|
+
}
|
|
401
|
+
return last;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/env.ts
|
|
405
|
+
var PROVIDER_KINDS = [
|
|
406
|
+
"fake",
|
|
407
|
+
"vertex",
|
|
408
|
+
"claude",
|
|
409
|
+
"gemini",
|
|
410
|
+
"openai",
|
|
411
|
+
"anthropic"
|
|
412
|
+
];
|
|
413
|
+
var DEFAULT_MODEL = {
|
|
414
|
+
fake: void 0,
|
|
415
|
+
vertex: "gemini-2.5-flash",
|
|
416
|
+
gemini: "gemini-2.5-flash",
|
|
417
|
+
openai: "gpt-5-mini",
|
|
418
|
+
anthropic: "claude-haiku-4-5",
|
|
419
|
+
claude: void 0
|
|
420
|
+
};
|
|
421
|
+
var API_KEY_ENV = {
|
|
422
|
+
gemini: "GEMINI_API_KEY",
|
|
423
|
+
openai: "OPENAI_API_KEY",
|
|
424
|
+
anthropic: "ANTHROPIC_API_KEY"
|
|
425
|
+
};
|
|
426
|
+
var DEFAULT_RETRIES = 1;
|
|
427
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
428
|
+
var DEFAULT_CLAUDE_TIMEOUT_MS = 12e4;
|
|
429
|
+
function intEnv(env, name, fallback, min, max) {
|
|
430
|
+
const raw = env[name];
|
|
431
|
+
if (raw === void 0 || raw === "") return fallback;
|
|
432
|
+
const n = Number.parseInt(raw, 10);
|
|
433
|
+
if (Number.isNaN(n) || n < min || n > max) {
|
|
434
|
+
throw new JudgeConfigError(`${name} must be an integer in [${min}, ${max}], got "${raw}"`);
|
|
435
|
+
}
|
|
436
|
+
return n;
|
|
437
|
+
}
|
|
438
|
+
function resolveJudgeServiceEnv(env) {
|
|
439
|
+
const provider = env.JUDGE_PROVIDER;
|
|
440
|
+
if (!provider || !PROVIDER_KINDS.includes(provider)) {
|
|
441
|
+
const list = PROVIDER_KINDS.map((k) => `"${k}"`).join(" | ");
|
|
442
|
+
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}"`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const resolved = {
|
|
447
|
+
provider,
|
|
448
|
+
retries: intEnv(env, "JUDGE_RETRIES", DEFAULT_RETRIES, 0, 10),
|
|
449
|
+
timeoutMs: intEnv(
|
|
450
|
+
env,
|
|
451
|
+
"JUDGE_TIMEOUT_MS",
|
|
452
|
+
provider === "claude" ? DEFAULT_CLAUDE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS,
|
|
453
|
+
1e3,
|
|
454
|
+
6e5
|
|
455
|
+
)
|
|
456
|
+
};
|
|
457
|
+
const model = env.JUDGE_MODEL || DEFAULT_MODEL[provider];
|
|
458
|
+
if (model) resolved.model = model;
|
|
459
|
+
if (provider === "vertex") {
|
|
460
|
+
const project = env.GOOGLE_CLOUD_PROJECT;
|
|
461
|
+
const location = env.GOOGLE_CLOUD_LOCATION;
|
|
462
|
+
if (!project || !location) {
|
|
463
|
+
throw new JudgeConfigError(
|
|
464
|
+
"JUDGE_PROVIDER=vertex requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (ADC auth, no API keys)"
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
resolved.project = project;
|
|
468
|
+
resolved.location = location;
|
|
469
|
+
if (env.JUDGE_ACCESS_TOKEN) resolved.accessToken = env.JUDGE_ACCESS_TOKEN;
|
|
470
|
+
}
|
|
471
|
+
const keyEnvName = API_KEY_ENV[provider];
|
|
472
|
+
if (keyEnvName) {
|
|
473
|
+
const apiKey = env[keyEnvName];
|
|
474
|
+
if (!apiKey) {
|
|
475
|
+
throw new JudgeConfigError(`JUDGE_PROVIDER=${provider} requires ${keyEnvName}`);
|
|
476
|
+
}
|
|
477
|
+
resolved.apiKey = apiKey;
|
|
478
|
+
}
|
|
479
|
+
if (provider === "claude") {
|
|
480
|
+
resolved.claudeBin = env.JUDGE_CLAUDE_BIN || "claude";
|
|
481
|
+
}
|
|
482
|
+
return resolved;
|
|
483
|
+
}
|
|
484
|
+
function resolveJudgeServerEnv(env) {
|
|
485
|
+
const resolved = {
|
|
486
|
+
host: env.JUDGE_HOST || "127.0.0.1",
|
|
487
|
+
port: intEnv(env, "JUDGE_PORT", 8790, 1, 65535)
|
|
488
|
+
};
|
|
489
|
+
if (env.JUDGE_TOKEN) resolved.token = env.JUDGE_TOKEN;
|
|
490
|
+
return resolved;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/judge-service.ts
|
|
494
|
+
function buildProvider(env) {
|
|
495
|
+
switch (env.provider) {
|
|
496
|
+
case "fake":
|
|
497
|
+
return new FakeJudgeProvider();
|
|
498
|
+
case "vertex":
|
|
499
|
+
return new VertexJudgeProvider({
|
|
500
|
+
project: env.project,
|
|
501
|
+
location: env.location,
|
|
502
|
+
model: env.model,
|
|
503
|
+
timeoutMs: env.timeoutMs,
|
|
504
|
+
...env.accessToken ? { accessToken: env.accessToken } : {}
|
|
505
|
+
});
|
|
506
|
+
case "gemini":
|
|
507
|
+
return new GeminiJudgeProvider({
|
|
508
|
+
apiKey: env.apiKey,
|
|
509
|
+
model: env.model,
|
|
510
|
+
timeoutMs: env.timeoutMs
|
|
511
|
+
});
|
|
512
|
+
case "openai":
|
|
513
|
+
return new OpenAiJudgeProvider({
|
|
514
|
+
apiKey: env.apiKey,
|
|
515
|
+
model: env.model,
|
|
516
|
+
timeoutMs: env.timeoutMs
|
|
517
|
+
});
|
|
518
|
+
case "anthropic":
|
|
519
|
+
return new AnthropicJudgeProvider({
|
|
520
|
+
apiKey: env.apiKey,
|
|
521
|
+
model: env.model,
|
|
522
|
+
timeoutMs: env.timeoutMs
|
|
523
|
+
});
|
|
524
|
+
case "claude":
|
|
525
|
+
return new ClaudeCliJudgeProvider({
|
|
526
|
+
bin: env.claudeBin,
|
|
527
|
+
timeoutMs: env.timeoutMs,
|
|
528
|
+
...env.model ? { model: env.model } : {}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function createJudgeService(env, provider) {
|
|
533
|
+
const p = provider ?? buildProvider(env);
|
|
534
|
+
return {
|
|
535
|
+
judge: (request) => judgeWithRetries(p, request, env.retries)
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function createJudgeServiceFromEnv(env) {
|
|
539
|
+
return createJudgeService(resolveJudgeServiceEnv(env));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/server.ts
|
|
543
|
+
import { createServer } from "http";
|
|
544
|
+
import { JUDGE_ROUTES } from "@unotest/protocol";
|
|
545
|
+
var MAX_BODY_BYTES = 1024 * 1024;
|
|
546
|
+
function startJudgeServer(service, env) {
|
|
547
|
+
const server = createServer((req, res) => {
|
|
548
|
+
void route(service, env, req, res);
|
|
549
|
+
});
|
|
550
|
+
return new Promise((resolve, reject) => {
|
|
551
|
+
server.once("error", reject);
|
|
552
|
+
server.listen(env.port, env.host, () => {
|
|
553
|
+
const addr = server.address();
|
|
554
|
+
const port = typeof addr === "object" && addr !== null ? addr.port : env.port;
|
|
555
|
+
resolve({
|
|
556
|
+
server,
|
|
557
|
+
port,
|
|
558
|
+
close: () => new Promise((res2, rej2) => server.close((e) => e ? rej2(e) : res2()))
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
async function route(service, env, req, res) {
|
|
564
|
+
const url = req.url ?? "/";
|
|
565
|
+
if (req.method === "GET" && url === JUDGE_ROUTES.health) {
|
|
566
|
+
sendJson(res, 200, { ok: true });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (req.method !== "POST" || url !== JUDGE_ROUTES.judge) {
|
|
570
|
+
sendError(res, 404, `unknown route ${req.method} ${url}`, "bad-request");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (env.token && req.headers.authorization !== `Bearer ${env.token}`) {
|
|
574
|
+
sendError(res, 401, "missing or wrong bearer token (JUDGE_TOKEN)", "unauthorized");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
let body;
|
|
578
|
+
try {
|
|
579
|
+
body = parseRequest(await readBody(req));
|
|
580
|
+
} catch (e) {
|
|
581
|
+
sendError(res, 400, e instanceof Error ? e.message : String(e), "bad-request");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
sendJson(res, 200, await service.judge(body));
|
|
586
|
+
} catch (e) {
|
|
587
|
+
if (e instanceof JudgeProviderError) {
|
|
588
|
+
sendError(res, 502, e.message, "provider-error");
|
|
589
|
+
} else if (e instanceof JudgeConfigError) {
|
|
590
|
+
sendError(res, 500, e.message, "internal");
|
|
591
|
+
} else {
|
|
592
|
+
sendError(res, 500, e instanceof Error ? e.message : String(e), "internal");
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function parseRequest(raw) {
|
|
597
|
+
let parsed;
|
|
598
|
+
try {
|
|
599
|
+
parsed = JSON.parse(raw);
|
|
600
|
+
} catch {
|
|
601
|
+
throw new Error("request body is not JSON");
|
|
602
|
+
}
|
|
603
|
+
const { rubric, text } = parsed;
|
|
604
|
+
if (typeof rubric !== "string" || rubric.trim() === "") {
|
|
605
|
+
throw new Error('request body needs a non-empty string "rubric"');
|
|
606
|
+
}
|
|
607
|
+
if (typeof text !== "string") {
|
|
608
|
+
throw new Error('request body needs a string "text"');
|
|
609
|
+
}
|
|
610
|
+
return { rubric, text };
|
|
611
|
+
}
|
|
612
|
+
function readBody(req) {
|
|
613
|
+
return new Promise((resolve, reject) => {
|
|
614
|
+
const chunks = [];
|
|
615
|
+
let size = 0;
|
|
616
|
+
req.on("data", (chunk) => {
|
|
617
|
+
size += chunk.length;
|
|
618
|
+
if (size > MAX_BODY_BYTES) {
|
|
619
|
+
reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`));
|
|
620
|
+
req.destroy();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
chunks.push(chunk);
|
|
624
|
+
});
|
|
625
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
626
|
+
req.on("error", reject);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
function sendJson(res, status, body) {
|
|
630
|
+
const payload = JSON.stringify(body);
|
|
631
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
632
|
+
res.end(payload);
|
|
633
|
+
}
|
|
634
|
+
function sendError(res, status, error, code) {
|
|
635
|
+
sendJson(res, status, { error, code });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export {
|
|
639
|
+
JudgeError,
|
|
640
|
+
JudgeConfigError,
|
|
641
|
+
JudgeProviderError,
|
|
642
|
+
FAKE_MODEL_ID,
|
|
643
|
+
parseFakeRubric,
|
|
644
|
+
FakeJudgeProvider,
|
|
645
|
+
VERDICT_PROMPT,
|
|
646
|
+
parseVerdictReply,
|
|
647
|
+
VertexJudgeProvider,
|
|
648
|
+
GeminiJudgeProvider,
|
|
649
|
+
OpenAiJudgeProvider,
|
|
650
|
+
AnthropicJudgeProvider,
|
|
651
|
+
ClaudeCliJudgeProvider,
|
|
652
|
+
judgeWithRetries,
|
|
653
|
+
resolveJudgeServiceEnv,
|
|
654
|
+
resolveJudgeServerEnv,
|
|
655
|
+
buildProvider,
|
|
656
|
+
createJudgeService,
|
|
657
|
+
createJudgeServiceFromEnv,
|
|
658
|
+
startJudgeServer
|
|
659
|
+
};
|
|
660
|
+
//# sourceMappingURL=chunk-4VUHPR26.js.map
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createJudgeService,
|
|
3
|
+
resolveJudgeServerEnv,
|
|
4
|
+
resolveJudgeServiceEnv,
|
|
5
|
+
startJudgeServer
|
|
6
|
+
} from "./chunk-4VUHPR26.js";
|
|
7
|
+
|
|
8
|
+
// src/cli.ts
|
|
9
|
+
async function main(argv = process.argv.slice(2)) {
|
|
10
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
11
|
+
printHelp();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
15
|
+
const { readFileSync } = await import("fs");
|
|
16
|
+
const pkg = JSON.parse(
|
|
17
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
18
|
+
);
|
|
19
|
+
console.log(pkg.version);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const serviceEnv = resolveJudgeServiceEnv(process.env);
|
|
23
|
+
const serverEnv = resolveJudgeServerEnv(process.env);
|
|
24
|
+
const started = await startJudgeServer(createJudgeService(serviceEnv), serverEnv);
|
|
25
|
+
console.log(
|
|
26
|
+
`unotest-judge listening on http://${serverEnv.host}:${started.port} (provider: ${serviceEnv.provider}${serviceEnv.model ? `, model: ${serviceEnv.model}` : ""})`
|
|
27
|
+
);
|
|
28
|
+
const shutdown = () => {
|
|
29
|
+
void started.close().finally(() => process.exit(0));
|
|
30
|
+
};
|
|
31
|
+
process.once("SIGINT", shutdown);
|
|
32
|
+
process.once("SIGTERM", shutdown);
|
|
33
|
+
}
|
|
34
|
+
function printHelp() {
|
|
35
|
+
console.log(
|
|
36
|
+
[
|
|
37
|
+
"unotest-judge \u2014 LLM-judge service for @unotest/web's assertJudge",
|
|
38
|
+
"",
|
|
39
|
+
"Usage: npx @unotest/judge [--help] [--version]",
|
|
40
|
+
"",
|
|
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"
|
|
59
|
+
].join("\n")
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
main
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { JudgeRequest, JudgeVerdict } from '@unotest/protocol';
|
|
2
|
+
export { JUDGE_ROUTES, JudgeErrorResponse, JudgeRequest, JudgeVerdict } from '@unotest/protocol';
|
|
3
|
+
import { Server } from 'node:http';
|
|
4
|
+
|
|
5
|
+
declare class JudgeError extends Error {
|
|
6
|
+
readonly context: Record<string, unknown>;
|
|
7
|
+
constructor(message: string, context?: Record<string, unknown>);
|
|
8
|
+
}
|
|
9
|
+
/** Provider / service configuration invalid or missing (env vars). */
|
|
10
|
+
declare class JudgeConfigError extends JudgeError {
|
|
11
|
+
}
|
|
12
|
+
/** The provider call itself failed — transport fault, non-2xx from the
|
|
13
|
+
* model backend, or an unparseable model reply. */
|
|
14
|
+
declare class JudgeProviderError extends JudgeError {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SingleVerdict {
|
|
18
|
+
pass: boolean;
|
|
19
|
+
reasoning: string;
|
|
20
|
+
/** Model id that produced this verdict (`fake` for the fake provider). */
|
|
21
|
+
model: string;
|
|
22
|
+
}
|
|
23
|
+
interface JudgeProvider {
|
|
24
|
+
/** One un-retried judgement. Throws JudgeProviderError on transport /
|
|
25
|
+
* parse faults; a clean `fail` verdict is a RESULT, not an error. */
|
|
26
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare const FAKE_MODEL_ID = "fake";
|
|
30
|
+
type Constraint = {
|
|
31
|
+
kind: "contains" | "not-contains";
|
|
32
|
+
needle: string;
|
|
33
|
+
};
|
|
34
|
+
declare function parseFakeRubric(rubric: string): Constraint[];
|
|
35
|
+
declare class FakeJudgeProvider implements JudgeProvider {
|
|
36
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface VertexProviderOptions {
|
|
40
|
+
project: string;
|
|
41
|
+
location: string;
|
|
42
|
+
model: string;
|
|
43
|
+
/** Static bearer token override (JUDGE_ACCESS_TOKEN). Absent → ADC. */
|
|
44
|
+
accessToken?: string;
|
|
45
|
+
/** Per-call wall-clock budget, ms. */
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
/** Injected fetch (tests). Defaults to global fetch. */
|
|
48
|
+
fetchImpl?: typeof fetch;
|
|
49
|
+
}
|
|
50
|
+
declare class VertexJudgeProvider implements JudgeProvider {
|
|
51
|
+
private readonly opts;
|
|
52
|
+
private readonly fetchImpl;
|
|
53
|
+
private tokenSource;
|
|
54
|
+
constructor(opts: VertexProviderOptions);
|
|
55
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
56
|
+
private accessToken;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GeminiProviderOptions {
|
|
60
|
+
apiKey: string;
|
|
61
|
+
model: string;
|
|
62
|
+
/** Per-call wall-clock budget, ms. */
|
|
63
|
+
timeoutMs: number;
|
|
64
|
+
/** Injected fetch (tests). Defaults to global fetch. */
|
|
65
|
+
fetchImpl?: typeof fetch;
|
|
66
|
+
}
|
|
67
|
+
declare class GeminiJudgeProvider implements JudgeProvider {
|
|
68
|
+
private readonly opts;
|
|
69
|
+
private readonly fetchImpl;
|
|
70
|
+
constructor(opts: GeminiProviderOptions);
|
|
71
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface OpenAiProviderOptions {
|
|
75
|
+
apiKey: string;
|
|
76
|
+
model: string;
|
|
77
|
+
/** Per-call wall-clock budget, ms. */
|
|
78
|
+
timeoutMs: number;
|
|
79
|
+
/** Injected fetch (tests). Defaults to global fetch. */
|
|
80
|
+
fetchImpl?: typeof fetch;
|
|
81
|
+
}
|
|
82
|
+
declare class OpenAiJudgeProvider implements JudgeProvider {
|
|
83
|
+
private readonly opts;
|
|
84
|
+
private readonly fetchImpl;
|
|
85
|
+
constructor(opts: OpenAiProviderOptions);
|
|
86
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface AnthropicProviderOptions {
|
|
90
|
+
apiKey: string;
|
|
91
|
+
model: string;
|
|
92
|
+
/** Per-call wall-clock budget, ms. */
|
|
93
|
+
timeoutMs: number;
|
|
94
|
+
/** Injected fetch (tests). Defaults to global fetch. */
|
|
95
|
+
fetchImpl?: typeof fetch;
|
|
96
|
+
}
|
|
97
|
+
declare class AnthropicJudgeProvider implements JudgeProvider {
|
|
98
|
+
private readonly opts;
|
|
99
|
+
private readonly fetchImpl;
|
|
100
|
+
constructor(opts: AnthropicProviderOptions);
|
|
101
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ExecFileResult {
|
|
105
|
+
stdout: string;
|
|
106
|
+
stderr: string;
|
|
107
|
+
}
|
|
108
|
+
type ExecFileFn = (file: string, args: string[], opts: {
|
|
109
|
+
timeout: number;
|
|
110
|
+
maxBuffer: number;
|
|
111
|
+
}) => Promise<ExecFileResult>;
|
|
112
|
+
interface ClaudeCliProviderOptions {
|
|
113
|
+
/** Binary to spawn (JUDGE_CLAUDE_BIN, default "claude"). */
|
|
114
|
+
bin: string;
|
|
115
|
+
/** Passed as `--model` only when set — otherwise the CLI's default. */
|
|
116
|
+
model?: string;
|
|
117
|
+
/** Per-call wall-clock budget, ms. */
|
|
118
|
+
timeoutMs: number;
|
|
119
|
+
/** Injected exec (tests). Defaults to node:child_process execFile. */
|
|
120
|
+
execImpl?: ExecFileFn;
|
|
121
|
+
}
|
|
122
|
+
declare class ClaudeCliJudgeProvider implements JudgeProvider {
|
|
123
|
+
private readonly opts;
|
|
124
|
+
constructor(opts: ClaudeCliProviderOptions);
|
|
125
|
+
judgeOnce(request: JudgeRequest): Promise<SingleVerdict>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
declare const VERDICT_PROMPT: (rubric: string, text: string) => string;
|
|
129
|
+
/** Parse the model's JSON verdict. Exported for unit tests. */
|
|
130
|
+
declare function parseVerdictReply(text: string, model: string): {
|
|
131
|
+
pass: boolean;
|
|
132
|
+
reasoning: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
declare function judgeWithRetries(provider: JudgeProvider, request: JudgeRequest, retries: number): Promise<JudgeVerdict>;
|
|
136
|
+
|
|
137
|
+
type JudgeProviderKind = "fake" | "vertex" | "claude" | "gemini" | "openai" | "anthropic";
|
|
138
|
+
interface JudgeServiceEnv {
|
|
139
|
+
provider: JudgeProviderKind;
|
|
140
|
+
/** Model id. Absent only for `claude` without JUDGE_MODEL (CLI default)
|
|
141
|
+
* and for `fake` (no model behind it). */
|
|
142
|
+
model?: string;
|
|
143
|
+
/** Extra provider calls allowed on `fail` (first `pass` wins). */
|
|
144
|
+
retries: number;
|
|
145
|
+
/** Per-provider-call wall-clock budget, ms. */
|
|
146
|
+
timeoutMs: number;
|
|
147
|
+
/** Vertex ADC target. */
|
|
148
|
+
project?: string;
|
|
149
|
+
location?: string;
|
|
150
|
+
/** Static bearer override (vertex) — skips google-auth-library. */
|
|
151
|
+
accessToken?: string;
|
|
152
|
+
/** API key (gemini | openai | anthropic). */
|
|
153
|
+
apiKey?: string;
|
|
154
|
+
/** Claude Code binary override (claude). */
|
|
155
|
+
claudeBin?: string;
|
|
156
|
+
}
|
|
157
|
+
declare function resolveJudgeServiceEnv(env: NodeJS.ProcessEnv): JudgeServiceEnv;
|
|
158
|
+
interface JudgeServerEnv {
|
|
159
|
+
host: string;
|
|
160
|
+
port: number;
|
|
161
|
+
/** Optional bearer token the server requires on every /judge call. */
|
|
162
|
+
token?: string;
|
|
163
|
+
}
|
|
164
|
+
declare function resolveJudgeServerEnv(env: NodeJS.ProcessEnv): JudgeServerEnv;
|
|
165
|
+
|
|
166
|
+
interface JudgeService {
|
|
167
|
+
judge(request: JudgeRequest): Promise<JudgeVerdict>;
|
|
168
|
+
}
|
|
169
|
+
declare function buildProvider(env: JudgeServiceEnv): JudgeProvider;
|
|
170
|
+
declare function createJudgeService(env: JudgeServiceEnv, provider?: JudgeProvider): JudgeService;
|
|
171
|
+
/** Everything from process-env in one call — the entry point @unotest/web's
|
|
172
|
+
* local mode uses. Throws JudgeConfigError with an actionable message on
|
|
173
|
+
* missing/malformed env. */
|
|
174
|
+
declare function createJudgeServiceFromEnv(env: NodeJS.ProcessEnv): JudgeService;
|
|
175
|
+
|
|
176
|
+
interface StartedJudgeServer {
|
|
177
|
+
server: Server;
|
|
178
|
+
port: number;
|
|
179
|
+
close(): Promise<void>;
|
|
180
|
+
}
|
|
181
|
+
declare function startJudgeServer(service: JudgeService, env: JudgeServerEnv): Promise<StartedJudgeServer>;
|
|
182
|
+
|
|
183
|
+
export { AnthropicJudgeProvider, ClaudeCliJudgeProvider, type ExecFileFn, type ExecFileResult, FAKE_MODEL_ID, FakeJudgeProvider, GeminiJudgeProvider, JudgeConfigError, JudgeError, type JudgeProvider, JudgeProviderError, type JudgeProviderKind, type JudgeServerEnv, type JudgeService, type JudgeServiceEnv, OpenAiJudgeProvider, type SingleVerdict, type StartedJudgeServer, VERDICT_PROMPT, VertexJudgeProvider, buildProvider, createJudgeService, createJudgeServiceFromEnv, judgeWithRetries, parseFakeRubric, parseVerdictReply, resolveJudgeServerEnv, resolveJudgeServiceEnv, startJudgeServer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnthropicJudgeProvider,
|
|
3
|
+
ClaudeCliJudgeProvider,
|
|
4
|
+
FAKE_MODEL_ID,
|
|
5
|
+
FakeJudgeProvider,
|
|
6
|
+
GeminiJudgeProvider,
|
|
7
|
+
JudgeConfigError,
|
|
8
|
+
JudgeError,
|
|
9
|
+
JudgeProviderError,
|
|
10
|
+
OpenAiJudgeProvider,
|
|
11
|
+
VERDICT_PROMPT,
|
|
12
|
+
VertexJudgeProvider,
|
|
13
|
+
buildProvider,
|
|
14
|
+
createJudgeService,
|
|
15
|
+
createJudgeServiceFromEnv,
|
|
16
|
+
judgeWithRetries,
|
|
17
|
+
parseFakeRubric,
|
|
18
|
+
parseVerdictReply,
|
|
19
|
+
resolveJudgeServerEnv,
|
|
20
|
+
resolveJudgeServiceEnv,
|
|
21
|
+
startJudgeServer
|
|
22
|
+
} from "./chunk-4VUHPR26.js";
|
|
23
|
+
|
|
24
|
+
// src/index.ts
|
|
25
|
+
import { JUDGE_ROUTES } from "@unotest/protocol";
|
|
26
|
+
export {
|
|
27
|
+
AnthropicJudgeProvider,
|
|
28
|
+
ClaudeCliJudgeProvider,
|
|
29
|
+
FAKE_MODEL_ID,
|
|
30
|
+
FakeJudgeProvider,
|
|
31
|
+
GeminiJudgeProvider,
|
|
32
|
+
JUDGE_ROUTES,
|
|
33
|
+
JudgeConfigError,
|
|
34
|
+
JudgeError,
|
|
35
|
+
JudgeProviderError,
|
|
36
|
+
OpenAiJudgeProvider,
|
|
37
|
+
VERDICT_PROMPT,
|
|
38
|
+
VertexJudgeProvider,
|
|
39
|
+
buildProvider,
|
|
40
|
+
createJudgeService,
|
|
41
|
+
createJudgeServiceFromEnv,
|
|
42
|
+
judgeWithRetries,
|
|
43
|
+
parseFakeRubric,
|
|
44
|
+
parseVerdictReply,
|
|
45
|
+
resolveJudgeServerEnv,
|
|
46
|
+
resolveJudgeServiceEnv,
|
|
47
|
+
startJudgeServer
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unotest/judge",
|
|
3
|
+
"version": "0.23.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"author": "Ivan Volkov <ivan@volkov.io>",
|
|
10
|
+
"homepage": "https://www.npmjs.com/package/@unotest/judge",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"bin": {
|
|
18
|
+
"unotest-judge": "bin/unotest-judge.js"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!dist/**/*.map",
|
|
29
|
+
"bin",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
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
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.10.0",
|
|
47
|
+
"tsup": "^8.5.1",
|
|
48
|
+
"tsx": "^4.19.2",
|
|
49
|
+
"typescript": "^5.7.2"
|
|
50
|
+
}
|
|
51
|
+
}
|