verikun 0.5.0 → 0.7.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,12 +225,110 @@ 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).
226
235
 
236
+ ## Suites — run a directory of tests
237
+
238
+ `vk suite <dir>` runs every `*.md` in a directory through the [`vk ai`](#ai--natural-language-tests)
239
+ engine, sequentially, against one shared device (local or [remote](#remote-devices--vk-server)):
240
+
241
+ ```sh
242
+ vk suite tests/ --app com.example.app # local device
243
+ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote device
244
+ ```
245
+
246
+ - **Ordering is lexicographic** — prefix files `01-…`, `02-…` to sequence them.
247
+ `README.md` is skipped (it documents the suite, it isn't a test).
248
+ - **Isolation between tests:** with `--app <id>`, the app's data is cleared before
249
+ each test (`pm clear`; iOS degrades to a force-stop since it has no per-app
250
+ reset). Without `--app`, make each test self-isolating (start with
251
+ `launch <pkg> --clear` in the prose).
252
+ - **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
253
+ its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
254
+ fails (or errors) doesn't stop the suite; the rest still run.
255
+ - **The suite writes an overview** to `./.verikun/suites/<id>/`:
256
+ - **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
257
+ steps, model repairs, cost, duration, and the run id, plus suite totals. This
258
+ is the **output contract for reporting** — upload/publish steps compose over
259
+ it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
260
+ - **`index.html`** — a summary page linking every test's `report.html`.
261
+ - **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
262
+ directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
263
+ every test; the model's key (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) is checked
264
+ up front.
265
+
266
+ ## Remote devices — `vk server`
267
+
268
+ CI runners don't have your phone plugged into them. `vk server` exposes a
269
+ locally-connected device to remote verikun clients so a **disposable CI runner**
270
+ can drive it — without a self-hosted runner executing arbitrary PR code on the
271
+ machine that owns the device:
272
+
273
+ ```sh
274
+ # On the machine with the device attached:
275
+ export VERIKUN_SERVER_AUTH_KEY=$(openssl rand -base64 32) # or let vk generate one
276
+ vk server --allow-install # 127.0.0.1:8391 by default
277
+ vk server --bind 100.64.0.7 --allow-install # expose on a tailnet IP
278
+
279
+ # From anywhere that can reach it:
280
+ export VERIKUN_SERVER=http://100.64.0.7:8391
281
+ export VERIKUN_SERVER_AUTH_KEY=<the same key>
282
+ vk install ./app-debug.apk --server "$VERIKUN_SERVER"
283
+ vk ai onboarding.md --server "$VERIKUN_SERVER"
284
+ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER"
285
+ ```
286
+
287
+ **Split execution.** The client runs the whole `vk ai` engine — compile, plan
288
+ cache, repairs, the Anthropic key, run recording, suite aggregation — and only
289
+ **validated device commands** cross the network, one HTTP round-trip per command
290
+ (selector auto-wait polls on the server, next to the device). Each step's detail
291
+ (selector, heal tier, resolved element, failure screenshot + hierarchy) returns
292
+ with the response and is spliced into the client's run, so the archived report is
293
+ identical to a local run's.
294
+
295
+ **Security model** (the server is the boundary, not the transport):
296
+
297
+ - **Auth is mandatory.** Pass a key via `--auth-key` / `VERIKUN_SERVER_AUTH_KEY`
298
+ (the env var keeps it out of `ps`), or one is generated and printed at startup.
299
+ Clients send it as a bearer token; comparison is constant-time.
300
+ `--allow-unsafe-anonymous` disables auth loudly — only for networks that are
301
+ themselves the boundary (e.g. a private tailnet), and it cannot be combined
302
+ with a key.
303
+ - **Only the validated grammar runs.** Every `/v1/exec` request passes the same
304
+ `validateNode` gate that guards `vk ai` model repairs: action verbs only
305
+ (`tap`/`text`/`assert`/`launch`/…), never `ui`/`log`, never a shell. The
306
+ device and platform are fixed when the server starts — client flags cannot
307
+ repoint them.
308
+ - **Installs are opt-in.** `POST /v1/install` requires `--allow-install` (a
309
+ read-only server refuses builds), accepts only single-file `.apk`/`.ipa`
310
+ uploads to a server-generated temp path (never a client path), and verifies a
311
+ sha256 of the body.
312
+ - **One run at a time.** A run-token holds the device lock; a second concurrent
313
+ caller gets `409`. The lock is released when the command finishes (so
314
+ `vk install` then `vk suite` chain seamlessly), and an idle lock (5 min
315
+ silent) is taken over, so a crashed CI job can't wedge the device.
316
+ - **Bind is loopback by default.** `--bind <addr>` opts into exposure. For a
317
+ NAT'd box, [Tailscale](https://tailscale.com) is the recommended transport; for
318
+ a public host, terminate TLS in front (the server itself speaks plain HTTP).
319
+ - Failure evidence (screenshots, UI hierarchies) crosses the authenticated
320
+ channel like the rest — same caveat as `vk log`: device output is not redacted.
321
+
322
+ ### CI recipe
323
+
324
+ [`.github/workflows/suite.yml`](.github/workflows/suite.yml) is a working
325
+ reference: a plain `ubuntu-latest` job builds verikun, installs the app build on
326
+ the remote device (`vk install --server`), runs `vk suite --server`, uploads
327
+ `.verikun/suites` + `.verikun/runs` as artifacts, and **fails the job when any
328
+ test fails** (the suite's exit code). Publishing anywhere else is a composable
329
+ step over the `index.json` manifest — the workflow includes commented `rclone`
330
+ (Google Drive) and `aws s3 cp` examples.
331
+
227
332
  ## Selectors
228
333
 
229
334
  ```
@@ -281,6 +386,8 @@ condition as a step in its own right.
281
386
  | `-d, --device <serial>` | Target a specific device (or `VERIKUN_DEVICE` / `ANDROID_SERIAL`). |
282
387
  | `-p, --platform <android\|ios>` | Platform (default `android`). `--ios` / `--android` are shortcuts. |
283
388
  | `-j, --json` | Machine-readable output (also serializes errors). |
389
+ | `--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. |
390
+ | `--auth-key <k>` | Key for `--server` (or `VERIKUN_SERVER_AUTH_KEY`, which keeps it out of `ps`). |
284
391
  | `--` | End flag parsing, so text/arguments may start with `-`. |
285
392
 
286
393
  ## Exit codes
@@ -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;
@@ -31,15 +31,15 @@ async function runPlan(plan, deps) {
31
31
  // A UI dump can fail transiently on a real device (uiautomator throws). Treat
32
32
  // that as "empty screen" rather than letting it abort the whole run — the rest
33
33
  // of the codebase treats dumps as recoverable (resolveOneWaiting re-polls).
34
- const safeElements = () => {
34
+ const safeElements = async () => {
35
35
  try {
36
- return deps.getElements();
36
+ return await deps.getElements();
37
37
  }
38
38
  catch {
39
39
  return [];
40
40
  }
41
41
  };
42
- const present = (selector) => {
42
+ const present = async (selector) => {
43
43
  // Re-fetch on a dump FAILURE (uiautomator can throw transiently) so a flaky dump at
44
44
  // a guard check doesn't silently read as "absent" and skip a body that should run.
45
45
  // Once a dump SUCCEEDS (even if empty) we trust it — no slow re-poll, so a genuinely
@@ -47,7 +47,7 @@ async function runPlan(plan, deps) {
47
47
  let els;
48
48
  for (let i = 0; i < 2 && els === undefined; i++) {
49
49
  try {
50
- els = deps.getElements();
50
+ els = await deps.getElements();
51
51
  }
52
52
  catch {
53
53
  /* transient dump failure — retry once before concluding "absent" */
@@ -92,7 +92,7 @@ async function runPlan(plan, deps) {
92
92
  failedStep: current,
93
93
  reason,
94
94
  candidates,
95
- hierarchy: safeElements(),
95
+ hierarchy: await safeElements(),
96
96
  });
97
97
  deps.cost.add(usage, 'repair');
98
98
  // The model may DECLINE (null) when the current screen has no element serving
@@ -149,7 +149,7 @@ async function runPlan(plan, deps) {
149
149
  case 'command':
150
150
  return execLeaf(node, where, replace);
151
151
  case 'if-present': {
152
- if (present(node.selector)) {
152
+ if (await present(node.selector)) {
153
153
  deps.log(`[ai] ${where}: if-present '${node.selector}' → present, running ${node.body.length} step(s)`);
154
154
  return walkBody(node.body, where);
155
155
  }
@@ -163,11 +163,11 @@ async function runPlan(plan, deps) {
163
163
  deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
164
164
  return { status: 'timeout' };
165
165
  }
166
- if (present(node.selector)) {
166
+ if (await present(node.selector)) {
167
167
  deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
168
168
  return { status: 'ok' };
169
169
  }
170
- const hash = structuralHash(safeElements());
170
+ const hash = structuralHash(await safeElements());
171
171
  if (i > 0 && hash === prevHash) {
172
172
  deps.log(`[ai] ${where}: repeat made no progress (screen unchanged) — stopping after ${i} iteration(s)`);
173
173
  return { status: 'ok' };
@@ -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;