verikun 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,6 +80,7 @@ vk screenshot # -> ./.verikun/screen.png
80
80
  | `screenshot [--out path] [--more] [--max px] [--full] [--json]` | Save a PNG (default `./.verikun/screen.png`); prints the path. [Downscaled](#screenshots) to a 700px longest edge by default to save tokens; `--more` bumps detail, `--max px` sets an exact cap, `--full` keeps the original. |
81
81
  | `launch <app> [--clear] [--no-restart]` / `stop <app>` | App lifecycle by package id (Android) / bundle id (iOS). `launch` **restarts by default** — it force-stops the app first (a no-op if it isn't running) so a rerun starts fresh instead of resurfacing a still-running instance's current screen; `--no-restart` skips that. `--clear` also wipes the app's local data (login/session, prefs, cache) for a fresh-install start. |
82
82
  | `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS unsupported: there is no per-app data reset. |
83
+ | `install <app.apk\|.ipa> [--server url]` | Install a build on the device (`adb install -r` / `idb install`). With `--server`, the file is uploaded to a remote [`vk server`](#remote-devices--vk-server) started with `--allow-install` (single-file `.apk`/`.ipa`, sha256-verified). |
83
84
 
84
85
  ### Batch
85
86
  | Command | Description |
@@ -89,7 +90,13 @@ vk screenshot # -> ./.verikun/screen.png
89
90
  ### AI
90
91
  | Command | Description |
91
92
  |---|---|
92
- | `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY`. See [AI](#ai--natural-language-tests). |
93
+ | `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--server url] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (per model). See [AI](#ai--natural-language-tests). |
94
+ | `suite <dir> [--app <id>] [--name n] [--server url] [--json]` (+ all `ai` flags) | Run every `*.md` in `<dir>` as one sequential suite with an overview report and a non-zero exit on failure — the CI gate. See [Suites](#suites--run-a-directory-of-tests). |
95
+
96
+ ### Remote
97
+ | Command | Description |
98
+ |---|---|
99
+ | `server [--bind addr] [--port n] [--auth-key k] [--allow-install] [--allow-unsafe-anonymous]` | Expose this machine's connected device to remote verikun clients (`ai`/`suite`/`install --server`). Auth is mandatory unless explicitly disabled; only verikun's validated command grammar is executable. See [Remote devices](#remote-devices--vk-server). |
93
100
 
94
101
  ### Environment
95
102
  | Command | Description |
@@ -190,7 +197,7 @@ printf 'launch com.example.app\nassert @home_tab\nrun archive smoke\n' | vk batc
190
197
  with no model calls on the happy path**. The model is woken only to *repair* a step
191
198
  whose selector stops resolving; a green run persists the repaired plan, so the next
192
199
  run is free again. That is what keeps a CI suite's steady-state token cost near zero.
193
- Needs `ANTHROPIC_API_KEY`.
200
+ Needs `ANTHROPIC_API_KEY` (Claude models) or `OPENAI_API_KEY` (OpenAI models).
194
201
 
195
202
  ```sh
196
203
  # onboarding.md (plain English):
@@ -218,11 +225,114 @@ a hard iteration cap and stop early if the screen stops changing.
218
225
  $3)** or the wall-clock passes **`--timeout` (default 15m)** — so a runaway loop or
219
226
  repair can't spend or hang without limit. `--cost-override <input/output>` overrides
220
227
  the bundled per-1M price table if it drifts.
221
- - **`--model`** picks the model (`claude-haiku-4-5` · `claude-sonnet-4-6` (default) ·
222
- `claude-opus-4-8` · `claude-fable-5`); **`--recompile`** ignores the cache.
228
+ - **`--model`** picks the model and its provider — Anthropic (`claude-haiku-4-5` ·
229
+ `claude-sonnet-4-6` (default) · `claude-opus-4-8` · `claude-fable-5`) or OpenAI
230
+ (`gpt-5.4-mini` · `gpt-5.4` · `gpt-5.5`), each read from its own key
231
+ (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`); **`--recompile`** ignores the cache.
223
232
  - An `ai` run records like any other flow, so it produces the same JUnit + HTML report —
224
233
  with the cost line and any **suggested test improvements** (workarounds the model
225
234
  applied, which you can fold back into the prose to stabilize the test and cut tokens).
235
+ - **Review screenshots are inserted automatically.** The compiler adds `screenshot` steps
236
+ around transitions and inside loops, so the report carries a before/after visual trail
237
+ for post-run review. They are dumped for humans, never read back by the model (no token
238
+ cost on replay), and never gate the test — a capture that hiccups is logged and skipped,
239
+ not a failure.
240
+
241
+ ## Suites — run a directory of tests
242
+
243
+ `vk suite <dir>` runs every `*.md` in a directory through the [`vk ai`](#ai--natural-language-tests)
244
+ engine, sequentially, against one shared device (local or [remote](#remote-devices--vk-server)):
245
+
246
+ ```sh
247
+ vk suite tests/ --app com.example.app # local device
248
+ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote device
249
+ ```
250
+
251
+ - **Ordering is lexicographic** — prefix files `01-…`, `02-…` to sequence them.
252
+ `README.md` is skipped (it documents the suite, it isn't a test).
253
+ - **Isolation between tests:** with `--app <id>`, the app's data is cleared before
254
+ each test (`pm clear`; iOS degrades to a force-stop since it has no per-app
255
+ reset). Without `--app`, make each test self-isolating (start with
256
+ `launch <pkg> --clear` in the prose).
257
+ - **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
258
+ its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
259
+ fails (or errors) doesn't stop the suite; the rest still run.
260
+ - **The suite writes an overview** to `./.verikun/suites/<id>/`:
261
+ - **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
262
+ steps, model repairs, cost, duration, and the run id, plus suite totals. This
263
+ is the **output contract for reporting** — upload/publish steps compose over
264
+ it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
265
+ - **`index.html`** — a summary page linking every test's `report.html`.
266
+ - **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
267
+ directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
268
+ every test; the model's key (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) is checked
269
+ up front.
270
+
271
+ ## Remote devices — `vk server`
272
+
273
+ CI runners don't have your phone plugged into them. `vk server` exposes a
274
+ locally-connected device to remote verikun clients so a **disposable CI runner**
275
+ can drive it — without a self-hosted runner executing arbitrary PR code on the
276
+ machine that owns the device:
277
+
278
+ ```sh
279
+ # On the machine with the device attached:
280
+ export VERIKUN_SERVER_AUTH_KEY=$(openssl rand -base64 32) # or let vk generate one
281
+ vk server --allow-install # 127.0.0.1:8391 by default
282
+ vk server --bind 100.64.0.7 --allow-install # expose on a tailnet IP
283
+
284
+ # From anywhere that can reach it:
285
+ export VERIKUN_SERVER=http://100.64.0.7:8391
286
+ export VERIKUN_SERVER_AUTH_KEY=<the same key>
287
+ vk install ./app-debug.apk --server "$VERIKUN_SERVER"
288
+ vk ai onboarding.md --server "$VERIKUN_SERVER"
289
+ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER"
290
+ ```
291
+
292
+ **Split execution.** The client runs the whole `vk ai` engine — compile, plan
293
+ cache, repairs, the Anthropic key, run recording, suite aggregation — and only
294
+ **validated device commands** cross the network, one HTTP round-trip per command
295
+ (selector auto-wait polls on the server, next to the device). Each step's detail
296
+ (selector, heal tier, resolved element, failure screenshot + hierarchy) returns
297
+ with the response and is spliced into the client's run, so the archived report is
298
+ identical to a local run's.
299
+
300
+ **Security model** (the server is the boundary, not the transport):
301
+
302
+ - **Auth is mandatory.** Pass a key via `--auth-key` / `VERIKUN_SERVER_AUTH_KEY`
303
+ (the env var keeps it out of `ps`), or one is generated and printed at startup.
304
+ Clients send it as a bearer token; comparison is constant-time.
305
+ `--allow-unsafe-anonymous` disables auth loudly — only for networks that are
306
+ themselves the boundary (e.g. a private tailnet), and it cannot be combined
307
+ with a key.
308
+ - **Only the validated grammar runs.** Every `/v1/exec` request passes the same
309
+ `validateNode` gate that guards `vk ai` model repairs: action verbs only
310
+ (`tap`/`text`/`assert`/`launch`/…), never `ui`/`log`, never a shell. The
311
+ device and platform are fixed when the server starts — client flags cannot
312
+ repoint them.
313
+ - **Installs are opt-in.** `POST /v1/install` requires `--allow-install` (a
314
+ read-only server refuses builds), accepts only single-file `.apk`/`.ipa`
315
+ uploads to a server-generated temp path (never a client path), and verifies a
316
+ sha256 of the body.
317
+ - **One run at a time.** A run-token holds the device lock; a second concurrent
318
+ caller gets `409`. The lock is released when the command finishes (so
319
+ `vk install` then `vk suite` chain seamlessly), and an idle lock (5 min
320
+ silent) is taken over, so a crashed CI job can't wedge the device.
321
+ - **Bind is loopback by default.** `--bind <addr>` opts into exposure. For a
322
+ NAT'd box, [Tailscale](https://tailscale.com) is the recommended transport; for
323
+ a public host, terminate TLS in front (the server itself speaks plain HTTP).
324
+ - Failure evidence (screenshots, UI hierarchies) crosses the authenticated
325
+ channel like the rest — same caveat as `vk log`: device output is not redacted.
326
+
327
+ ### CI recipe
328
+
329
+ [`.github/workflows/suite.yml`](.github/workflows/suite.yml) is a working
330
+ reference: a plain `ubuntu-latest` job builds verikun, installs the app build on
331
+ the remote device (`vk install --server`), runs `vk suite --server`, uploads
332
+ `.verikun/suites` + `.verikun/runs` as artifacts, and **fails the job when any
333
+ test fails** (the suite's exit code). Publishing anywhere else is a composable
334
+ step over the `index.json` manifest — the workflow includes commented `rclone`
335
+ (Google Drive) and `aws s3 cp` examples.
226
336
 
227
337
  ## Selectors
228
338
 
@@ -281,6 +391,8 @@ condition as a step in its own right.
281
391
  | `-d, --device <serial>` | Target a specific device (or `VERIKUN_DEVICE` / `ANDROID_SERIAL`). |
282
392
  | `-p, --platform <android\|ios>` | Platform (default `android`). `--ios` / `--android` are shortcuts. |
283
393
  | `-j, --json` | Machine-readable output (also serializes errors). |
394
+ | `--server <url>` | For `ai`/`suite`/`install`: run against a remote [`vk server`](#remote-devices--vk-server) (or `VERIKUN_SERVER`). The server's device/platform apply. |
395
+ | `--auth-key <k>` | Key for `--server` (or `VERIKUN_SERVER_AUTH_KEY`, which keeps it out of `ps`). |
284
396
  | `--` | End flag parsing, so text/arguments may start with `-`. |
285
397
 
286
398
  ## Exit codes
@@ -317,6 +429,13 @@ safely resample (palette, 16-bit, interlaced) are written through untouched, so
317
429
  screenshot is never corrupted — only sometimes left full-size (noted on stderr).
318
430
  Failure-evidence captures in test-run reports stay full-resolution for debugging.
319
431
 
432
+ **Read-back vs evidence.** The downscaling above matters when an agent *reads a
433
+ screenshot back into its context* to decide the next action — that is the token cost to
434
+ manage. A screenshot taken purely as **report evidence and never read back** costs nothing
435
+ at runtime, so driving a flow to a report should capture liberally around transitions.
436
+ `vk ai` does this automatically (see [AI](#ai--natural-language-tests)); when driving by
437
+ hand, `vk screenshot` around each screen change and leave the PNG in the report.
438
+
320
439
  ## How it works
321
440
 
322
441
  ```
@@ -1,23 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CostTracker = exports.DEFAULT_MAX_COST_USD = exports.DEFAULT_MODEL = exports.ALLOWED_MODELS = exports.MODEL_PRICES = void 0;
4
+ exports.providerFor = providerFor;
4
5
  exports.resolveModel = resolveModel;
5
6
  exports.parseCostOverride = parseCostOverride;
6
7
  exports.priceFor = priceFor;
7
8
  exports.estimateCostUsd = estimateCostUsd;
8
9
  const errors_1 = require("../errors");
9
- // Per-1M-token prices (Anthropic, cached 2026-05-26). This table WILL drift as
10
- // pricing changes between releases `--cost-override <input/output>` is the escape
11
- // hatch, and is authoritative when supplied. The --model allowlist is exactly the
12
- // keys of this table, so the two can never disagree.
13
- exports.MODEL_PRICES = {
14
- 'claude-haiku-4-5': { input: 1, output: 5 },
15
- 'claude-sonnet-4-6': { input: 3, output: 15 },
16
- 'claude-opus-4-8': { input: 5, output: 25 },
17
- 'claude-fable-5': { input: 10, output: 50 },
10
+ // Per-1M-token prices + owning provider the SINGLE source of truth. MODEL_PRICES,
11
+ // ALLOWED_MODELS and providerFor all derive from this, so the --model allowlist, its
12
+ // price and its backend can never disagree. Prices WILL drift between releases
13
+ // (Anthropic cached 2026-05-26; OpenAI 2026-07-02) `--cost-override <input/output>`
14
+ // is the escape hatch and is authoritative when supplied. Every model here bills cached
15
+ // input at ~0.1x (Anthropic + OpenAI gpt-5.x alike), matching CACHE_READ_MULT below.
16
+ const MODELS = {
17
+ 'claude-haiku-4-5': { input: 1, output: 5, provider: 'anthropic' },
18
+ 'claude-sonnet-4-6': { input: 3, output: 15, provider: 'anthropic' },
19
+ 'claude-opus-4-8': { input: 5, output: 25, provider: 'anthropic' },
20
+ 'claude-fable-5': { input: 10, output: 50, provider: 'anthropic' },
21
+ 'gpt-5.4-mini': { input: 0.75, output: 4.5, provider: 'openai' },
22
+ 'gpt-5.4': { input: 2.5, output: 15, provider: 'openai' },
23
+ 'gpt-5.5': { input: 5, output: 30, provider: 'openai' },
18
24
  };
19
- exports.ALLOWED_MODELS = Object.keys(exports.MODEL_PRICES);
25
+ exports.MODEL_PRICES = Object.fromEntries(Object.entries(MODELS).map(([m, s]) => [m, { input: s.input, output: s.output }]));
26
+ exports.ALLOWED_MODELS = Object.keys(MODELS);
20
27
  exports.DEFAULT_MODEL = 'claude-sonnet-4-6';
28
+ /** Resolve which provider backend serves a model (unknown → anthropic, the default). */
29
+ function providerFor(model) {
30
+ return MODELS[model]?.provider ?? 'anthropic';
31
+ }
21
32
  /** Default total-run cost ceiling for `vk ai` when --max-cost-usd is not given, so a
22
33
  * runaway compile/repair loop can't spend unbounded tokens. */
23
34
  exports.DEFAULT_MAX_COST_USD = 3;
@@ -8,6 +8,11 @@ const ir_1 = require("./ir");
8
8
  const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map((f) => (f.value === 'true' ? `--${f.name}` : `--${f.name} ${f.value}`))]
9
9
  .join(' ')
10
10
  .trim();
11
+ /** A screenshot leaf is best-effort review evidence, not a gate. The `vk ai`
12
+ * grammar has the model sprinkle them around transitions, so a capture that
13
+ * fails (a device hiccup on screencap) must never turn a green run red — see
14
+ * the guard in execLeaf. */
15
+ const isScreenshotLeaf = (leaf) => leaf.command === 'screenshot' || leaf.command === 'shot';
11
16
  /** A structural fingerprint of the screen: sorted id+text+type set. Used for the
12
17
  * loop no-progress check — deliberately NOT the raw hierarchy (its node ordering
13
18
  * is nondeterministic between identical states, which would false-trip). */
@@ -31,15 +36,15 @@ async function runPlan(plan, deps) {
31
36
  // A UI dump can fail transiently on a real device (uiautomator throws). Treat
32
37
  // that as "empty screen" rather than letting it abort the whole run — the rest
33
38
  // of the codebase treats dumps as recoverable (resolveOneWaiting re-polls).
34
- const safeElements = () => {
39
+ const safeElements = async () => {
35
40
  try {
36
- return deps.getElements();
41
+ return await deps.getElements();
37
42
  }
38
43
  catch {
39
44
  return [];
40
45
  }
41
46
  };
42
- const present = (selector) => {
47
+ const present = async (selector) => {
43
48
  // Re-fetch on a dump FAILURE (uiautomator can throw transiently) so a flaky dump at
44
49
  // a guard check doesn't silently read as "absent" and skip a body that should run.
45
50
  // Once a dump SUCCEEDS (even if empty) we trust it — no slow re-poll, so a genuinely
@@ -47,7 +52,7 @@ async function runPlan(plan, deps) {
47
52
  let els;
48
53
  for (let i = 0; i < 2 && els === undefined; i++) {
49
54
  try {
50
- els = deps.getElements();
55
+ els = await deps.getElements();
51
56
  }
52
57
  catch {
53
58
  /* transient dump failure — retry once before concluding "absent" */
@@ -92,7 +97,7 @@ async function runPlan(plan, deps) {
92
97
  failedStep: current,
93
98
  reason,
94
99
  candidates,
95
- hierarchy: safeElements(),
100
+ hierarchy: await safeElements(),
96
101
  });
97
102
  deps.cost.add(usage, 'repair');
98
103
  // The model may DECLINE (null) when the current screen has no element serving
@@ -129,6 +134,19 @@ async function runPlan(plan, deps) {
129
134
  }
130
135
  if (outcome.code === 0)
131
136
  return { status: 'ok' };
137
+ // A review screenshot is best-effort evidence, never a gate. The grammar has the
138
+ // model insert them liberally around transitions, so a capture that fails (a
139
+ // device hiccup on screencap) must not fail an otherwise-green run. Log it,
140
+ // downgrade the just-recorded failed step to a clean pass (markLastStepHealed
141
+ // sets status=passed/exitCode=0 and drops the failure evidence) so the report and
142
+ // JUnit stay consistent with the green run, then continue. Scoped to screenshot/
143
+ // shot — every other command's failure stays terminal.
144
+ if (isScreenshotLeaf(current)) {
145
+ const why = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
146
+ deps.log(`[ai] ${where}: screenshot capture failed (${why}) — continuing (best-effort review screenshot)`);
147
+ deps.markHealed?.(`screenshot capture failed (${why}) — skipped (best-effort)`);
148
+ return { status: 'ok' };
149
+ }
132
150
  if (isHealable(outcome)) {
133
151
  return { status: 'fail', where, reason: `unresolved after ${maxRepairs} repair attempt(s): ${outcome.error.message.split('\n')[0]}` };
134
152
  }
@@ -149,7 +167,7 @@ async function runPlan(plan, deps) {
149
167
  case 'command':
150
168
  return execLeaf(node, where, replace);
151
169
  case 'if-present': {
152
- if (present(node.selector)) {
170
+ if (await present(node.selector)) {
153
171
  deps.log(`[ai] ${where}: if-present '${node.selector}' → present, running ${node.body.length} step(s)`);
154
172
  return walkBody(node.body, where);
155
173
  }
@@ -163,11 +181,11 @@ async function runPlan(plan, deps) {
163
181
  deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
164
182
  return { status: 'timeout' };
165
183
  }
166
- if (present(node.selector)) {
184
+ if (await present(node.selector)) {
167
185
  deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
168
186
  return { status: 'ok' };
169
187
  }
170
- const hash = structuralHash(safeElements());
188
+ const hash = structuralHash(await safeElements());
171
189
  if (i > 0 && hash === prevHash) {
172
190
  deps.log(`[ai] ${where}: repeat made no progress (screen unchanged) — stopping after ${i} iteration(s)`);
173
191
  return { status: 'ok' };
@@ -54,7 +54,12 @@ RULES:
54
54
  - assert is for VERIFICATION only and is terminal — never use it as a step you expect to
55
55
  fail. Put genuinely-optional UI behind if-present.
56
56
  - Prefer resource-id / accessibility selectors over visible text where possible.
57
- - Translate the test literally and minimally; do not invent steps the prose does not imply.`;
57
+ - Translate the test literally and minimally: do not invent ACTION steps (tap/text/swipe/key/assert)
58
+ the prose does not imply. The ONE exception is screenshot — insert screenshot steps liberally as
59
+ post-run review evidence: after each screen transition (launch, a navigation tap, a submit, a
60
+ swipe/scroll) AND inside if-present/repeat bodies, so a failing branch or loop iteration is visible
61
+ in the report. Screenshots never affect the result; err toward too many. They are dumped into the
62
+ report for humans and never read back, so they are free on replay.`;
58
63
  exports.REPAIR_GRAMMAR = `A single step in a verikun plan failed to resolve its selector against the live screen
59
64
  (shown below). Decide between two outcomes — and be STRICT:
60
65
 
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OpenAiProvider = void 0;
4
+ exports.mapUsage = mapUsage;
5
+ exports.toStrictSchema = toStrictSchema;
6
+ const errors_1 = require("../errors");
7
+ const format_1 = require("../ui/format");
8
+ const ir_1 = require("./ir");
9
+ const grammar_1 = require("./grammar");
10
+ // The OpenAI provider: OpenAI's Chat Completions API over Node's built-in fetch — no
11
+ // SDK, honoring the repo's zero-runtime-dependency rule (a sibling to claude.ts). The
12
+ // LLM ecosystem converged on this /chat/completions shape, so this same class drives any
13
+ // OpenAI-compatible endpoint (Groq, xAI, Together, DeepSeek, Gemini-compat) by pointing
14
+ // `baseUrl` at it — that is the whole point of doing it here rather than per vendor.
15
+ //
16
+ // Structured output: response_format json_schema with strict:true, so generation is
17
+ // HARD-CONSTRAINED to the plan/repair schema. json_object mode is not enough — a weaker
18
+ // model (e.g. gpt-5.4-mini) emits flags as a map instead of the {name,value}[] the IR
19
+ // requires, which parsePlan then rejects. The shared ir.ts schemas are adapted to
20
+ // OpenAI's strict dialect at call time by toStrictSchema (no duplication, no ir.ts
21
+ // change); engine.ts's parsePlan/validateNode stays the execution trust boundary
22
+ // regardless. The model runs here ONLY on compile + repair, never on replay.
23
+ const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
24
+ // Per-request wall-clock cap so a stalled connection can't hang the run past its
25
+ // --timeout deadline (the engine checks the deadline only between calls, not during one).
26
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
27
+ // gpt-5.x are reasoning models: they take reasoning_effort, require max_completion_tokens
28
+ // (not max_tokens), and reject a custom temperature. Map verikun's effort scale onto
29
+ // OpenAI's (whose ceiling is 'high'), and only send it when the caller asked for one.
30
+ // INVARIANT: every OpenAI model in cost.ts's registry is a reasoning model that accepts
31
+ // reasoning_effort. If a non-reasoning model is ever added there, gate this send behind an
32
+ // allowlist like claude.ts's EFFORT_MODELS (else it 400s → exit 2 with no retry).
33
+ const EFFORT_MAP = {
34
+ low: 'low',
35
+ medium: 'medium',
36
+ high: 'high',
37
+ xhigh: 'high',
38
+ max: 'high',
39
+ };
40
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
+ const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 15000);
42
+ /** Map OpenAI's usage onto the normalized (Anthropic-shaped) Usage the CostTracker
43
+ * prices. OpenAI's prompt_tokens INCLUDES the cached tokens, so subtract them out:
44
+ * uncached prompt → input_tokens (full price), cached → cache_read_input_tokens (0.1x).
45
+ * OpenAI has no separate cache-write charge, so cache_creation is 0. completion_tokens
46
+ * already includes reasoning tokens on gpt-5.x, so output is priced correctly. A response
47
+ * with no usage block maps to all-zeros (cost under-counts) — acceptable because the
48
+ * repair loop is also bounded by maxRepairs and the --timeout deadline, not cost alone. */
49
+ function mapUsage(u) {
50
+ const cached = u?.prompt_tokens_details?.cached_tokens ?? 0;
51
+ const prompt = u?.prompt_tokens ?? 0;
52
+ return {
53
+ input_tokens: Math.max(0, prompt - cached),
54
+ output_tokens: u?.completion_tokens ?? 0,
55
+ cache_read_input_tokens: cached,
56
+ cache_creation_input_tokens: 0,
57
+ };
58
+ }
59
+ /** Adapt a JSON Schema to OpenAI's strict Structured-Outputs dialect: every object must
60
+ * set additionalProperties:false and list ALL its properties in `required`, so a
61
+ * previously-optional field is kept but made nullable. Non-mutating — lets the shared
62
+ * ir.ts schemas stay the single source of truth while OpenAI hard-constrains generation. */
63
+ function toStrictSchema(schema) {
64
+ if (Array.isArray(schema))
65
+ return schema.map(toStrictSchema);
66
+ if (!schema || typeof schema !== 'object')
67
+ return schema;
68
+ const src = schema;
69
+ const out = {};
70
+ for (const [k, v] of Object.entries(src))
71
+ out[k] = toStrictSchema(v);
72
+ const props = out.properties;
73
+ if (props && typeof props === 'object' && !Array.isArray(props)) {
74
+ const p = props;
75
+ const keys = Object.keys(p);
76
+ const wasRequired = new Set(Array.isArray(out.required) ? out.required : []);
77
+ out.additionalProperties = false;
78
+ out.required = keys;
79
+ for (const k of keys)
80
+ if (!wasRequired.has(k))
81
+ p[k] = makeNullable(p[k]);
82
+ }
83
+ return out;
84
+ }
85
+ /** Widen a property schema to also admit null (how strict mode expresses "optional"). */
86
+ function makeNullable(prop) {
87
+ if (!prop || typeof prop !== 'object')
88
+ return prop;
89
+ const p = prop;
90
+ if (Array.isArray(p.anyOf))
91
+ return { ...p, anyOf: [...p.anyOf, { type: 'null' }] };
92
+ if (typeof p.type === 'string') {
93
+ const next = { ...p, type: [p.type, 'null'] };
94
+ if (Array.isArray(p.enum))
95
+ next.enum = [...p.enum, null];
96
+ return next;
97
+ }
98
+ if (Array.isArray(p.type)) {
99
+ return { ...p, type: [...p.type.filter((t) => t !== 'null'), 'null'] };
100
+ }
101
+ return { anyOf: [prop, { type: 'null' }] };
102
+ }
103
+ class OpenAiProvider {
104
+ opts;
105
+ constructor(opts) {
106
+ this.opts = opts;
107
+ }
108
+ async compile(input) {
109
+ const parts = [];
110
+ if (input.pkg)
111
+ parts.push(`App package: ${input.pkg}`);
112
+ parts.push(`Platform: ${input.platform}`);
113
+ if (input.seed) {
114
+ parts.push('A plan compiled for a PREVIOUS build of this app follows. Reuse it where the test still holds; ' +
115
+ 'change only what the test now requires. PRIOR PLAN:\n' +
116
+ JSON.stringify(input.seed, null, 2));
117
+ }
118
+ parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
119
+ // Generous completion budget: on reasoning models the plan JSON shares this ceiling
120
+ // with reasoning tokens, so leave headroom (a 'length' finish is surfaced as an error).
121
+ const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 16384);
122
+ return { plan: (0, ir_1.parsePlan)(json), usage };
123
+ }
124
+ async repair(ctx) {
125
+ const parts = ['FAILED STEP: ' + JSON.stringify(ctx.failedStep), 'FAILURE: ' + ctx.reason];
126
+ if (ctx.candidates && ctx.candidates.length) {
127
+ parts.push(`The selector matched ${ctx.candidates.length} elements (ambiguous) — pick a more specific selector for the SAME intended element, or give_up if none of them is it.`);
128
+ }
129
+ parts.push('CURRENT SCREEN:\n' + (0, format_1.formatCompact)(ctx.hierarchy));
130
+ const { json, usage } = await this.call(grammar_1.REPAIR_GRAMMAR, parts.join('\n\n'), ir_1.REPAIR_DECISION_JSON_SCHEMA, 4096);
131
+ const decision = (json ?? {});
132
+ if (decision.decision === 'give_up') {
133
+ return {
134
+ replaceStep: null,
135
+ declineReason: decision.reason?.trim() || 'no element on the current screen matches the step intent',
136
+ usage,
137
+ };
138
+ }
139
+ // Hand the proposed leaf back UNVALIDATED — engine.ts validates every repair against
140
+ // the grammar before splicing (it is the execution trust boundary and can't assume a
141
+ // provider validated). A missing/invalid step is rejected there as a failed repair.
142
+ return { replaceStep: (decision.step ?? null), usage };
143
+ }
144
+ async call(system, user, schema, maxTokens) {
145
+ const body = {
146
+ model: this.opts.model,
147
+ max_completion_tokens: maxTokens,
148
+ // Hard-constrain generation to the schema (strict Structured Outputs), adapting the
149
+ // shared ir.ts schema to OpenAI's strict dialect. parsePlan/validateNode still gates.
150
+ response_format: {
151
+ type: 'json_schema',
152
+ json_schema: { name: 'verikun_output', strict: true, schema: toStrictSchema(schema) },
153
+ },
154
+ messages: [
155
+ { role: 'system', content: system },
156
+ { role: 'user', content: user },
157
+ ],
158
+ };
159
+ if (this.opts.effort) {
160
+ const mapped = EFFORT_MAP[this.opts.effort];
161
+ if (mapped)
162
+ body.reasoning_effort = mapped;
163
+ }
164
+ const res = await this.fetchWithRetry(body);
165
+ const choice = res.choices?.[0];
166
+ if (choice?.message?.refusal) {
167
+ throw new errors_1.CliError(`Model refused the request: ${choice.message.refusal}`, 1);
168
+ }
169
+ if (choice?.finish_reason === 'length') {
170
+ throw new errors_1.CliError('Model output was truncated (finish_reason=length) before a complete result — raise the budget or shorten the test.', 1);
171
+ }
172
+ if (choice?.finish_reason === 'content_filter') {
173
+ throw new errors_1.CliError('Model output was blocked by the content filter.', 1);
174
+ }
175
+ const text = (choice?.message?.content ?? '').trim();
176
+ if (!text)
177
+ throw new errors_1.CliError('Model returned an empty response.', 1);
178
+ let json;
179
+ try {
180
+ json = JSON.parse(text);
181
+ }
182
+ catch {
183
+ throw new errors_1.CliError('Model output was not valid JSON.', 1);
184
+ }
185
+ return { json, usage: mapUsage(res.usage) };
186
+ }
187
+ async fetchWithRetry(body) {
188
+ const maxRetries = this.opts.maxRetries ?? 4;
189
+ const timeoutMs = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
190
+ const doFetch = this.opts.fetchImpl ?? ((url, init) => fetch(url, init));
191
+ const doSleep = this.opts.sleepImpl ?? sleep;
192
+ const url = (this.opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '') + '/chat/completions';
193
+ let attempt = 0;
194
+ for (;;) {
195
+ // Abort a stalled request after timeoutMs so it can't hang forever; the abort is
196
+ // caught below and retried like any other network error (bounded by maxRetries).
197
+ const controller = new AbortController();
198
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
199
+ let res;
200
+ try {
201
+ res = await doFetch(url, {
202
+ method: 'POST',
203
+ headers: {
204
+ 'content-type': 'application/json',
205
+ authorization: `Bearer ${this.opts.apiKey}`,
206
+ },
207
+ body: JSON.stringify(body),
208
+ signal: controller.signal,
209
+ });
210
+ }
211
+ catch (e) {
212
+ if (attempt++ >= maxRetries)
213
+ throw new errors_1.CliError(`OpenAI API request failed: ${e.message}`, 3);
214
+ await doSleep(backoffMs(attempt));
215
+ continue;
216
+ }
217
+ finally {
218
+ clearTimeout(timer);
219
+ }
220
+ if (res.ok) {
221
+ try {
222
+ return (await res.json());
223
+ }
224
+ catch {
225
+ // A 2xx with a non-JSON body (proxy/API corruption) — map to the env exit code
226
+ // rather than letting a raw SyntaxError escape as an unhandled throw.
227
+ throw new errors_1.CliError('OpenAI returned a non-JSON success body.', 3);
228
+ }
229
+ }
230
+ // Retry 429 + 5xx with backoff, honoring Retry-After (no SDK to do it for us).
231
+ if ((res.status === 429 || res.status >= 500) && attempt++ < maxRetries) {
232
+ const retryAfter = Number(res.headers.get('retry-after'));
233
+ await doSleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
234
+ continue;
235
+ }
236
+ const errText = await res.text().catch(() => '');
237
+ // 401/403 = auth/permission (env); 400 = bad request (usage); else env.
238
+ const code = res.status === 401 || res.status === 403 ? 3 : res.status === 400 ? 2 : 3;
239
+ throw new errors_1.CliError(`OpenAI API error ${res.status}: ${errText.slice(0, 500)}`, code);
240
+ }
241
+ }
242
+ }
243
+ exports.OpenAiProvider = OpenAiProvider;