@qatonic_innovations/qaios 0.1.2 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +78 -20
  2. package/dist/index.js +515 -63
  3. package/package.json +3 -4
package/README.md CHANGED
@@ -35,18 +35,20 @@ The published package is `@qatonic_innovations/qaios`; the installed **command i
35
35
 
36
36
  ### Requirements
37
37
 
38
- - **Node.js 20 LTS recommended.** QAIOS bundles a native SQLite module
39
- (better-sqlite3) for its local audit log; Node 20 LTS has the widest
40
- prebuilt-binary coverage. Newer Node usually works but may need to compile
41
- the binary (build tools + network access).
42
- - An **Anthropic API key** — get one at
43
- [console.anthropic.com](https://console.anthropic.com/settings/keys), then
44
- put it in your environment:
38
+ - **Node.js 22 LTS or newer.** QAIOS uses Node's **built-in** SQLite
39
+ (`node:sqlite`, stable in Node 22) for its local audit log **no native
40
+ module to compile**, so `npm install` is just a download. (On Node < 22 the
41
+ built-in isn't available; upgrade Node.)
42
+ - An **LLM provider API key.** QAIOS uses **Anthropic** by default — get a key
43
+ at [console.anthropic.com](https://console.anthropic.com/settings/keys) and
44
+ export it:
45
45
  ```bash
46
46
  export ANTHROPIC_API_KEY=sk-ant-... # macOS/Linux
47
47
  setx ANTHROPIC_API_KEY "sk-ant-..." # Windows (open a NEW shell after)
48
48
  ```
49
- The key is read from the environment and **never written to disk** by QAIOS.
49
+ Prefer **OpenAI**? Set `llm.provider: openai` and export `OPENAI_API_KEY`
50
+ instead — see [Choose your LLM provider](#choose-your-llm-provider) below.
51
+ Keys are read from the environment and **never written to disk** by QAIOS.
50
52
  - **Playwright** in your project, for `qaios run` / `snapshot` / `explore` / `a11y`:
51
53
  ```bash
52
54
  npm i -D @playwright/test && npx playwright install
@@ -57,14 +59,9 @@ The published package is `@qatonic_innovations/qaios`; the installed **command i
57
59
 
58
60
  ### Install troubleshooting
59
61
 
60
- If `qaios init` fails with a SQLite/native-binding error
61
- (`Could not load the native SQLite module`):
62
-
63
- - Confirm your Node version: `node -v` (prefer 20 LTS).
64
- - Rebuild the binary: `npm rebuild better-sqlite3` — or reinstall qaios.
65
- - Behind a proxy/firewall? The prebuilt binary is fetched from GitHub
66
- release assets; allow that host, or install C/C++ build tools so it can
67
- compile locally.
62
+ QAIOS has **no native dependencies** it uses Node's built-in SQLite — so
63
+ install is friction-free. If `qaios init` reports a SQLite/`node:sqlite` error,
64
+ your Node is too old: run `node -v` and upgrade to **Node 22 LTS or newer**.
68
65
 
69
66
  `qaios doctor` will tell you exactly which check failed and what to run.
70
67
 
@@ -159,8 +156,7 @@ mode: LITE # LITE | FULL | TRUST
159
156
  app:
160
157
  baseUrl: https://staging.myapp.com
161
158
  llm:
162
- provider: anthropic
163
- apiKeyEnv: ANTHROPIC_API_KEY # key is read from env, never stored
159
+ provider: anthropic # anthropic | openai
164
160
  maxLlmCallsPerWorkflow: 15
165
161
  costAlertThresholdUsdCents: 50
166
162
  testing:
@@ -176,6 +172,68 @@ defects:
176
172
 
177
173
  `qaios config show` prints the resolved config; `qaios config set <key> <value>` validates against the schema before writing. **API keys are never written to config** — they come from environment variables.
178
174
 
175
+ ### Choose your LLM provider
176
+
177
+ QAIOS works with **Anthropic** (default) or **OpenAI**. Set the provider in
178
+ `.qaios/config.yaml` and export that provider's key:
179
+
180
+ ```yaml
181
+ # Anthropic (default)
182
+ llm:
183
+ provider: anthropic # reads ANTHROPIC_API_KEY
184
+ ```
185
+
186
+ ```yaml
187
+ # OpenAI
188
+ llm:
189
+ provider: openai # reads OPENAI_API_KEY
190
+ model: gpt-4o # optional; default gpt-4o (try gpt-4o-mini for lower cost)
191
+ ```
192
+
193
+ ```bash
194
+ export OPENAI_API_KEY=sk-...
195
+ qaios doctor # confirms the configured provider's key is reachable
196
+ ```
197
+
198
+ The rest of QAIOS is provider-blind — every command (`test`, `run`, `fix`,
199
+ `explore`, `a11y`, …) works identically on either. Structured output uses each
200
+ provider's native guaranteed-schema mode (Anthropic forced tool-use / OpenAI
201
+ strict function calling), so generated artifacts stay schema-valid. You can
202
+ override the key's env-var name with `llm.apiKeyEnv` if needed.
203
+
204
+ #### Picking a model
205
+
206
+ `llm.model` is a free-form string passed straight to the provider — **any model
207
+ that provider's API accepts works**, not just the defaults. When `llm.model` is
208
+ omitted, QAIOS uses a sensible default per provider:
209
+
210
+ | Provider | Default | Other examples you can set |
211
+ | ----------- | ------------------- | ------------------------------------------------- |
212
+ | `anthropic` | `claude-sonnet-4-6` | `claude-opus-4-7`, `claude-haiku-4-5-20251001`, … |
213
+ | `openai` | `gpt-4o` | `gpt-4o-mini` (cheaper), `gpt-4.1`, … |
214
+
215
+ ```yaml
216
+ llm:
217
+ provider: openai
218
+ model: gpt-4o-mini # any OpenAI model id
219
+ ```
220
+
221
+ Two things to know about non-default models:
222
+
223
+ - **Cost tracking.** QAIOS knows exact pricing for a built-in set of models
224
+ (Sonnet/Opus/Haiku and gpt-4o/4o-mini/4.1/4.1-mini). A model **outside** that
225
+ set still runs fine, but the USD figure in the audit log is approximate — it
226
+ bills at a default rate and prints a one-time `no pricing for model …`
227
+ warning. The per-workflow call/cost **cap still applies** regardless.
228
+ - **Structured output.** QAIOS relies on the provider's strict tool/function
229
+ calling. The defaults are chosen for strong schema adherence; a much older or
230
+ unusual model may fail validation more often (the built-in retry loop
231
+ recovers from occasional misses).
232
+
233
+ The defaults are the **live-validated** starting points — every skill is
234
+ exercised end-to-end against them. Other models share the same code path but
235
+ aren't individually certified.
236
+
179
237
  ### Operating modes
180
238
 
181
239
  - **LITE** (default) — HIGH/CRITICAL risk pauses for review; routine work flows through.
@@ -190,8 +248,8 @@ qaios config set mode TRUST
190
248
 
191
249
  ## Cost & privacy
192
250
 
193
- - All v0.1 skills use Claude Sonnet. A typical `qaios test` costs ~**$0.04–0.10**. Each workflow is capped (default `min(15 calls, $0.50)`, configurable) and aborts if exceeded.
194
- - **Local-first.** No telemetry, no phone-home. The only outbound traffic is (a) LLM calls to Anthropic with the prompts QAIOS builds, and (b) any MCP servers you configure. You bring your own API key; QAIOS does not proxy your requests. See [SECURITY.md](https://github.com/qatonic/qaios/blob/main/SECURITY.md) for exactly what's sent.
251
+ - Skills run on Claude Sonnet (Anthropic) or gpt-4o (OpenAI) per your `llm.provider`. A typical `qaios test` costs ~**$0.04–0.10**. Each workflow is capped (default `min(15 calls, $0.50)`, configurable) and aborts if exceeded.
252
+ - **Local-first.** No telemetry, no phone-home. The only outbound traffic is (a) LLM calls to your configured provider (Anthropic or OpenAI) with the prompts QAIOS builds, and (b) any MCP servers you configure. You bring your own API key; QAIOS does not proxy your requests. See [SECURITY.md](https://github.com/qatonic/qaios/blob/main/SECURITY.md) for exactly what's sent.
195
253
 
196
254
  ---
197
255
 
package/dist/index.js CHANGED
@@ -6,11 +6,12 @@ import { realpathSync, readFileSync, existsSync, rmSync, mkdirSync, writeFileSyn
6
6
  import path12 from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { Command, InvalidArgumentError } from 'commander';
9
- import Database from 'better-sqlite3';
10
9
  import { createHash } from 'crypto';
10
+ import { createRequire } from 'module';
11
11
  import { z, ZodError } from 'zod';
12
12
  import { monotonicFactory } from 'ulid';
13
13
  import Anthropic from '@anthropic-ai/sdk';
14
+ import OpenAI from 'openai';
14
15
  import { zodToJsonSchema } from 'zod-to-json-schema';
15
16
  import { spawn, spawnSync } from 'child_process';
16
17
  import { tmpdir } from 'os';
@@ -843,14 +844,23 @@ var QaiosConfig = z.object({
843
844
  version: z.literal(1),
844
845
  mode: Mode.default("LITE"),
845
846
  llm: z.object({
846
- provider: z.literal("anthropic").default("anthropic"),
847
- apiKeyEnv: z.string().default("ANTHROPIC_API_KEY"),
848
- // v0.1: every tier maps to claude-sonnet-4-6 (the current Sonnet
849
- // when v0.1 went GA — see PRICING table in runtime/llm/cost.ts).
850
- // There is no real tier routing yet. The skill-declared tier
851
- // (1/2/3) is recorded in the audit log so v0.5 can light up real
852
- // per-tier routing without re-tagging skills. Do not branch on
853
- // tier in skill code; treat the resolved model as opaque.
847
+ // Which LLM provider backs every skill. Default stays anthropic so
848
+ // existing projects are unchanged. Set `openai` to use OpenAI instead;
849
+ // the provider's own key env var (ANTHROPIC_API_KEY / OPENAI_API_KEY)
850
+ // is read automatically — see runtime/config/env.ts + llm/factory.ts.
851
+ provider: z.enum(["anthropic", "openai"]).default("anthropic"),
852
+ // Optional override of the env var that holds the API key. When unset,
853
+ // the factory picks the provider's conventional var.
854
+ apiKeyEnv: z.string().optional(),
855
+ // Optional explicit model id. When set, it overrides the per-tier
856
+ // models below for ALL tiers (single-model v0.1 behavior). When unset,
857
+ // the factory resolves the provider's default (Sonnet for anthropic,
858
+ // gpt-4o for openai).
859
+ model: z.string().optional(),
860
+ // v0.1: every tier maps to one model (claude-sonnet-4-6 for anthropic).
861
+ // There is no real tier routing yet. The skill-declared tier (1/2/3) is
862
+ // recorded in the audit log so v0.5 can light up real per-tier routing
863
+ // without re-tagging skills. Do not branch on tier in skill code.
854
864
  models: z.object({
855
865
  tier1: z.string().default("claude-sonnet-4-6"),
856
866
  // v0.5 → opus
@@ -1124,6 +1134,92 @@ function ensureDirExists(dir) {
1124
1134
  throw err;
1125
1135
  }
1126
1136
  }
1137
+ var nodeRequire = createRequire(import.meta.url);
1138
+ var { DatabaseSync } = nodeRequire("node:sqlite");
1139
+ var StatementAdapter = class {
1140
+ constructor(stmt) {
1141
+ this.stmt = stmt;
1142
+ }
1143
+ stmt;
1144
+ run(...params) {
1145
+ return this.stmt.run(...params);
1146
+ }
1147
+ get(...params) {
1148
+ return this.stmt.get(...params);
1149
+ }
1150
+ all(...params) {
1151
+ return this.stmt.all(...params);
1152
+ }
1153
+ };
1154
+ var SqliteDb = class {
1155
+ db;
1156
+ /** Current nesting depth of `transaction()` — drives BEGIN vs SAVEPOINT. */
1157
+ txDepth = 0;
1158
+ constructor(location) {
1159
+ this.db = new DatabaseSync(location);
1160
+ }
1161
+ prepare(sql) {
1162
+ return new StatementAdapter(this.db.prepare(sql));
1163
+ }
1164
+ exec(sql) {
1165
+ this.db.exec(sql);
1166
+ }
1167
+ /**
1168
+ * better-sqlite3-compatible `pragma()`. Two call shapes are used in the repo:
1169
+ * - a SET: `pragma('journal_mode = WAL')` / `pragma('foreign_keys = ON')`
1170
+ * - a READ: `pragma('journal_mode', { simple: true })` → the scalar value
1171
+ * node:sqlite has no pragma helper, so route through exec/prepare. A statement
1172
+ * containing `=` is a set (no useful result); otherwise it's a read.
1173
+ */
1174
+ pragma(source, opts = {}) {
1175
+ const isSet = source.includes("=");
1176
+ if (isSet) {
1177
+ this.db.exec(`PRAGMA ${source}`);
1178
+ return void 0;
1179
+ }
1180
+ const rows = this.db.prepare(`PRAGMA ${source}`).all();
1181
+ if (opts.simple === true) {
1182
+ const first = rows[0];
1183
+ if (first === void 0) return void 0;
1184
+ const keys = Object.keys(first);
1185
+ return keys.length > 0 ? first[keys[0]] : void 0;
1186
+ }
1187
+ return rows;
1188
+ }
1189
+ /**
1190
+ * better-sqlite3-compatible `transaction(fn)`: returns a callable that runs
1191
+ * `fn` atomically, rolling back on throw. node:sqlite has no transaction
1192
+ * helper, so wrap explicitly — and like better-sqlite3, be **savepoint-aware**
1193
+ * so NESTED `transaction()` calls don't error with "cannot start a transaction
1194
+ * within a transaction". The outermost call uses BEGIN/COMMIT/ROLLBACK; a
1195
+ * nested call uses a uniquely-named SAVEPOINT / RELEASE / ROLLBACK TO.
1196
+ */
1197
+ transaction(fn) {
1198
+ return (...args) => {
1199
+ const nested = this.txDepth > 0;
1200
+ const savepoint = `qaios_sp_${this.txDepth}`;
1201
+ this.db.exec(nested ? `SAVEPOINT ${savepoint}` : "BEGIN");
1202
+ this.txDepth += 1;
1203
+ try {
1204
+ const result = fn(...args);
1205
+ this.db.exec(nested ? `RELEASE ${savepoint}` : "COMMIT");
1206
+ this.txDepth -= 1;
1207
+ return result;
1208
+ } catch (err) {
1209
+ this.txDepth -= 1;
1210
+ try {
1211
+ this.db.exec(nested ? `ROLLBACK TO ${savepoint}` : "ROLLBACK");
1212
+ if (nested) this.db.exec(`RELEASE ${savepoint}`);
1213
+ } catch {
1214
+ }
1215
+ throw err;
1216
+ }
1217
+ };
1218
+ }
1219
+ close() {
1220
+ this.db.close();
1221
+ }
1222
+ };
1127
1223
  var __dirname$1 = path12.dirname(fileURLToPath(import.meta.url));
1128
1224
  var DEFAULT_MIGRATIONS_DIR = path12.resolve(__dirname$1, "migrations");
1129
1225
  var Storage = class _Storage {
@@ -1140,7 +1236,7 @@ var Storage = class _Storage {
1140
1236
  * (recommended for tests).
1141
1237
  */
1142
1238
  static open(dbPath, opts = {}) {
1143
- const db = new Database(dbPath);
1239
+ const db = new SqliteDb(dbPath);
1144
1240
  try {
1145
1241
  if (!opts.disableWal && dbPath !== ":memory:") {
1146
1242
  db.pragma("journal_mode = WAL");
@@ -2033,18 +2129,29 @@ function computeEntryHash(entry) {
2033
2129
  const { hash: _ignored, ...rest } = entry;
2034
2130
  return sha256Hex2(canonicalize(rest));
2035
2131
  }
2036
- function readAnthropicApiKey() {
2037
- const v = process.env["ANTHROPIC_API_KEY"];
2132
+ function readKey(name) {
2133
+ const v = process.env[name];
2038
2134
  if (typeof v !== "string") return void 0;
2039
2135
  return v.trim().length > 0 ? v : void 0;
2040
2136
  }
2137
+ function readAnthropicApiKey() {
2138
+ return readKey("ANTHROPIC_API_KEY");
2139
+ }
2140
+ function readOpenAiApiKey() {
2141
+ return readKey("OPENAI_API_KEY");
2142
+ }
2143
+ function readProviderApiKey(provider, apiKeyEnvOverride) {
2144
+ if (apiKeyEnvOverride !== void 0 && apiKeyEnvOverride.trim().length > 0) {
2145
+ return readKey(apiKeyEnvOverride);
2146
+ }
2147
+ return provider === "openai" ? readOpenAiApiKey() : readAnthropicApiKey();
2148
+ }
2041
2149
  function snapshotEnv() {
2042
- const k = readAnthropicApiKey();
2150
+ const a = readAnthropicApiKey();
2151
+ const o = readOpenAiApiKey();
2043
2152
  return {
2044
- anthropicApiKey: {
2045
- present: k !== void 0,
2046
- length: k?.length ?? 0
2047
- }
2153
+ anthropicApiKey: { present: a !== void 0, length: a?.length ?? 0 },
2154
+ openaiApiKey: { present: o !== void 0, length: o?.length ?? 0 }
2048
2155
  };
2049
2156
  }
2050
2157
  var PRICING = {
@@ -2062,6 +2169,12 @@ var PRICING = {
2062
2169
  cacheReadPerMTok: 0.3,
2063
2170
  cacheWritePerMTok: 3.75
2064
2171
  },
2172
+ "claude-opus-4-8": {
2173
+ inputPerMTok: 15,
2174
+ outputPerMTok: 75,
2175
+ cacheReadPerMTok: 1.5,
2176
+ cacheWritePerMTok: 18.75
2177
+ },
2065
2178
  "claude-opus-4-7": {
2066
2179
  inputPerMTok: 15,
2067
2180
  outputPerMTok: 75,
@@ -2079,16 +2192,52 @@ var PRICING = {
2079
2192
  outputPerMTok: 4,
2080
2193
  cacheReadPerMTok: 0.08,
2081
2194
  cacheWritePerMTok: 1
2195
+ },
2196
+ // ── OpenAI (provider: openai) ──────────────────────────────────────────
2197
+ // USD per 1M tokens. OpenAI bills cached input at ~0.5× the input rate;
2198
+ // there is no separate cache-write charge, so cacheWritePerMTok is 0.
2199
+ "gpt-4o": {
2200
+ inputPerMTok: 2.5,
2201
+ outputPerMTok: 10,
2202
+ cacheReadPerMTok: 1.25,
2203
+ cacheWritePerMTok: 0
2204
+ },
2205
+ "gpt-4o-mini": {
2206
+ inputPerMTok: 0.15,
2207
+ outputPerMTok: 0.6,
2208
+ cacheReadPerMTok: 0.075,
2209
+ cacheWritePerMTok: 0
2210
+ },
2211
+ "gpt-4.1": {
2212
+ inputPerMTok: 2,
2213
+ outputPerMTok: 8,
2214
+ cacheReadPerMTok: 0.5,
2215
+ cacheWritePerMTok: 0
2216
+ },
2217
+ "gpt-4.1-mini": {
2218
+ inputPerMTok: 0.4,
2219
+ outputPerMTok: 1.6,
2220
+ cacheReadPerMTok: 0.1,
2221
+ cacheWritePerMTok: 0
2082
2222
  }
2083
2223
  };
2084
2224
  var DEFAULT_PRICING = PRICING["claude-sonnet-4-6"];
2225
+ var _unknownModelWarned = /* @__PURE__ */ new Set();
2085
2226
  function computeCostUsdCents(model, usage) {
2086
- const p = PRICING[model] ?? DEFAULT_PRICING;
2227
+ const p = PRICING[model];
2228
+ if (p === void 0 && !_unknownModelWarned.has(model)) {
2229
+ _unknownModelWarned.add(model);
2230
+ process.stderr.write(
2231
+ `(qaios) warning: no pricing for model "${model}" \u2014 billing at the default rate. Costs in the audit log are approximate for this model.
2232
+ `
2233
+ );
2234
+ }
2235
+ const pricing = p ?? DEFAULT_PRICING;
2087
2236
  const tok = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
2088
- const inputUsd = tok(usage.inputTokens) / 1e6 * p.inputPerMTok;
2089
- const outputUsd = tok(usage.outputTokens) / 1e6 * p.outputPerMTok;
2090
- const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 * p.cacheReadPerMTok;
2091
- const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 * p.cacheWritePerMTok;
2237
+ const inputUsd = tok(usage.inputTokens) / 1e6 * pricing.inputPerMTok;
2238
+ const outputUsd = tok(usage.outputTokens) / 1e6 * pricing.outputPerMTok;
2239
+ const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 * pricing.cacheReadPerMTok;
2240
+ const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 * pricing.cacheWritePerMTok;
2092
2241
  const totalCents = (inputUsd + outputUsd + cacheReadUsd + cacheWriteUsd) * 100;
2093
2242
  return Math.ceil(totalCents);
2094
2243
  }
@@ -2177,6 +2326,260 @@ function mapResponse(response, latencyMs) {
2177
2326
  stopReason: response.stop_reason
2178
2327
  };
2179
2328
  }
2329
+ var UNSUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
2330
+ "minLength",
2331
+ "maxLength",
2332
+ "pattern",
2333
+ "format",
2334
+ "minimum",
2335
+ "maximum",
2336
+ "exclusiveMinimum",
2337
+ "exclusiveMaximum",
2338
+ "multipleOf",
2339
+ "minItems",
2340
+ "maxItems",
2341
+ "uniqueItems",
2342
+ "minProperties",
2343
+ "maxProperties",
2344
+ "default",
2345
+ "$schema",
2346
+ "patternProperties"
2347
+ ]);
2348
+ function isPlainObject(v) {
2349
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2350
+ }
2351
+ function makeNullable(node) {
2352
+ const t = node["type"];
2353
+ if (typeof t === "string") {
2354
+ if (t === "null") return node;
2355
+ return { ...node, type: [t, "null"] };
2356
+ }
2357
+ if (Array.isArray(t)) {
2358
+ return t.includes("null") ? node : { ...node, type: [...t, "null"] };
2359
+ }
2360
+ return { anyOf: [node, { type: "null" }] };
2361
+ }
2362
+ function resolveRef(ref, root) {
2363
+ if (!ref.startsWith("#/")) return void 0;
2364
+ const parts = ref.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
2365
+ let cur = root;
2366
+ for (const part of parts) {
2367
+ if (!isPlainObject(cur) && !Array.isArray(cur)) return void 0;
2368
+ cur = cur[part];
2369
+ }
2370
+ return isPlainObject(cur) ? cur : void 0;
2371
+ }
2372
+ function inlineRefs(node, root, seen) {
2373
+ if (Array.isArray(node)) return node.map((n) => inlineRefs(n, root, seen));
2374
+ if (!isPlainObject(node)) return node;
2375
+ const ref = node["$ref"];
2376
+ if (typeof ref === "string") {
2377
+ if (seen.has(ref)) return { ...node };
2378
+ const target = resolveRef(ref, root);
2379
+ if (target !== void 0) {
2380
+ const next = new Set(seen).add(ref);
2381
+ const { $ref: _drop, ...siblings } = node;
2382
+ const inlined = inlineRefs(target, root, next);
2383
+ return { ...inlined, ...siblings };
2384
+ }
2385
+ }
2386
+ const out = {};
2387
+ for (const [k, v] of Object.entries(node)) out[k] = inlineRefs(v, root, seen);
2388
+ return out;
2389
+ }
2390
+ function openaiStrictify(schema) {
2391
+ if (!isPlainObject(schema)) return schema;
2392
+ return strictifyNode(inlineRefs(schema, schema, /* @__PURE__ */ new Set()));
2393
+ }
2394
+ function strictifyNode(schema) {
2395
+ if (!isPlainObject(schema)) return schema;
2396
+ const out = {};
2397
+ for (const [key, value] of Object.entries(schema)) {
2398
+ if (UNSUPPORTED_KEYWORDS.has(key)) continue;
2399
+ if (key === "properties" && isPlainObject(value)) {
2400
+ const props = {};
2401
+ for (const [propName, propSchema] of Object.entries(value)) {
2402
+ props[propName] = strictifyNode(propSchema);
2403
+ }
2404
+ out["properties"] = props;
2405
+ continue;
2406
+ }
2407
+ if (key === "items") {
2408
+ out["items"] = Array.isArray(value) ? value.map((v) => strictifyNode(v)) : strictifyNode(value);
2409
+ continue;
2410
+ }
2411
+ if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
2412
+ out[key] = value.map((v) => strictifyNode(v));
2413
+ continue;
2414
+ }
2415
+ out[key] = value;
2416
+ }
2417
+ if (out["type"] === "object" && isPlainObject(out["properties"])) {
2418
+ const props = out["properties"];
2419
+ const originalRequired = new Set(
2420
+ Array.isArray(schema["required"]) ? schema["required"] : []
2421
+ );
2422
+ const allKeys = Object.keys(props);
2423
+ for (const k of allKeys) {
2424
+ if (!originalRequired.has(k)) {
2425
+ props[k] = makeNullable(props[k]);
2426
+ }
2427
+ }
2428
+ out["required"] = allKeys;
2429
+ out["additionalProperties"] = false;
2430
+ }
2431
+ return out;
2432
+ }
2433
+ var DEFAULT_OPENAI_MODEL = "gpt-4o";
2434
+ var DEFAULT_MAX_TOKENS2 = 4096;
2435
+ var OpenAiClient = class {
2436
+ client;
2437
+ explicitApiKey;
2438
+ defaultModel;
2439
+ defaultMaxTokens;
2440
+ constructor(opts = {}) {
2441
+ this.client = opts.client ?? null;
2442
+ this.explicitApiKey = opts.apiKey;
2443
+ this.defaultModel = opts.defaultModel ?? DEFAULT_OPENAI_MODEL;
2444
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? DEFAULT_MAX_TOKENS2;
2445
+ }
2446
+ resolveClient() {
2447
+ if (this.client !== null) return this.client;
2448
+ const apiKey = this.explicitApiKey ?? readOpenAiApiKey();
2449
+ if (apiKey === void 0 || apiKey.trim().length === 0) {
2450
+ throw new LlmError({
2451
+ code: "qaios.llm.api_key_missing",
2452
+ message: 'OPENAI_API_KEY is not set (llm.provider is "openai").\nGet one at https://platform.openai.com/api-keys, then:\n export OPENAI_API_KEY=sk-\u2026\n`qaios doctor` will confirm the key is reachable.'
2453
+ });
2454
+ }
2455
+ const sdk = new OpenAI({ apiKey });
2456
+ this.client = sdk;
2457
+ return this.client;
2458
+ }
2459
+ async call(opts) {
2460
+ const client = this.resolveClient();
2461
+ const model = opts.model ?? this.defaultModel;
2462
+ const params = {
2463
+ model,
2464
+ max_tokens: opts.maxTokens ?? this.defaultMaxTokens,
2465
+ // OpenAI carries the system prompt as a leading system-role message.
2466
+ messages: [
2467
+ { role: "system", content: opts.systemPrompt },
2468
+ { role: "user", content: opts.userPrompt }
2469
+ ]
2470
+ };
2471
+ if (opts.temperature !== void 0) params["temperature"] = opts.temperature;
2472
+ if (opts.tools && opts.tools.length > 0) {
2473
+ params["tools"] = opts.tools.map((t) => ({
2474
+ type: "function",
2475
+ function: {
2476
+ name: t.name,
2477
+ description: t.description,
2478
+ parameters: openaiStrictify(t.input_schema),
2479
+ strict: true
2480
+ }
2481
+ }));
2482
+ }
2483
+ if (opts.toolChoice !== void 0) {
2484
+ params["tool_choice"] = mapToolChoice(opts.toolChoice);
2485
+ }
2486
+ const reqOpts = {};
2487
+ if (opts.signal !== void 0) reqOpts.signal = opts.signal;
2488
+ const start = Date.now();
2489
+ const response = await client.chat.completions.create(params, reqOpts);
2490
+ const latencyMs = Date.now() - start;
2491
+ return mapResponse2(response, latencyMs);
2492
+ }
2493
+ };
2494
+ function mapToolChoice(choice) {
2495
+ switch (choice.type) {
2496
+ case "auto":
2497
+ return "auto";
2498
+ case "any":
2499
+ return "required";
2500
+ case "tool":
2501
+ return { type: "function", function: { name: choice.name } };
2502
+ }
2503
+ }
2504
+ function mapFinishReason(reason) {
2505
+ switch (reason) {
2506
+ case "stop":
2507
+ return "end_turn";
2508
+ case "tool_calls":
2509
+ case "function_call":
2510
+ return "tool_use";
2511
+ case "length":
2512
+ return "max_tokens";
2513
+ default:
2514
+ return reason;
2515
+ }
2516
+ }
2517
+ function mapResponse2(response, latencyMs) {
2518
+ const choice = response.choices[0];
2519
+ const message = choice?.message;
2520
+ const output = message?.content ?? "";
2521
+ const toolCalls = (message?.tool_calls ?? []).map((tc) => ({
2522
+ id: tc.id,
2523
+ name: tc.function.name,
2524
+ // OpenAI returns function arguments as a JSON STRING — parse to match the
2525
+ // parsed-object shape Anthropic's tool_use blocks give us. A malformed
2526
+ // payload surfaces as an LlmError rather than a silent {}.
2527
+ input: parseArguments(tc.function.arguments, tc.function.name)
2528
+ }));
2529
+ const promptTokens = response.usage?.prompt_tokens ?? 0;
2530
+ const cachedTokens = response.usage?.prompt_tokens_details?.cached_tokens ?? 0;
2531
+ const usage = {
2532
+ // OpenAI's prompt_tokens INCLUDES cached tokens; bill the uncached portion
2533
+ // at the input rate and the cached portion at the cache-read rate.
2534
+ inputTokens: Math.max(0, promptTokens - cachedTokens),
2535
+ outputTokens: response.usage?.completion_tokens ?? 0
2536
+ };
2537
+ if (cachedTokens > 0) usage.cacheReadTokens = cachedTokens;
2538
+ return {
2539
+ output,
2540
+ toolCalls,
2541
+ usage,
2542
+ model: response.model,
2543
+ latencyMs,
2544
+ costUsdCents: computeCostUsdCents(response.model, usage),
2545
+ stopReason: mapFinishReason(choice?.finish_reason ?? null)
2546
+ };
2547
+ }
2548
+ function parseArguments(raw, toolName) {
2549
+ let parsed;
2550
+ try {
2551
+ parsed = JSON.parse(raw);
2552
+ } catch (err) {
2553
+ throw new LlmError({
2554
+ code: "qaios.llm.malformed_tool_arguments",
2555
+ message: `OpenAI returned non-JSON arguments for tool "${toolName}".`,
2556
+ cause: err
2557
+ });
2558
+ }
2559
+ return stripNulls(parsed);
2560
+ }
2561
+ function stripNulls(value) {
2562
+ if (Array.isArray(value)) return value.map(stripNulls);
2563
+ if (value !== null && typeof value === "object") {
2564
+ const out = {};
2565
+ for (const [k, v] of Object.entries(value)) {
2566
+ if (v === null) continue;
2567
+ out[k] = stripNulls(v);
2568
+ }
2569
+ return out;
2570
+ }
2571
+ return value;
2572
+ }
2573
+ function defaultModelFor(provider) {
2574
+ return provider === "openai" ? DEFAULT_OPENAI_MODEL : DEFAULT_LLM_MODEL;
2575
+ }
2576
+ function createLlmClient(opts = {}) {
2577
+ if (opts.client !== void 0) return opts.client;
2578
+ const provider = opts.provider ?? "anthropic";
2579
+ const defaultModel = opts.model ?? defaultModelFor(provider);
2580
+ const clientOpts = opts.apiKey !== void 0 ? { apiKey: opts.apiKey, defaultModel } : { defaultModel };
2581
+ return provider === "openai" ? new OpenAiClient(clientOpts) : new LlmClient(clientOpts);
2582
+ }
2180
2583
  var SkillError = class extends Error {
2181
2584
  code;
2182
2585
  skillId;
@@ -6021,7 +6424,7 @@ function parseOpenApi(specPath) {
6021
6424
  function isRecord(v) {
6022
6425
  return typeof v === "object" && v !== null && !Array.isArray(v);
6023
6426
  }
6024
- function resolveRef(refValue, doc, depth = 0) {
6427
+ function resolveRef2(refValue, doc, depth = 0) {
6025
6428
  if (depth > 4) return { $ref: refValue };
6026
6429
  if (!refValue.startsWith("#/")) return { $ref: refValue };
6027
6430
  const segments = refValue.slice(2).split("/").map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"));
@@ -6032,7 +6435,7 @@ function resolveRef(refValue, doc, depth = 0) {
6032
6435
  if (cursor === void 0) return { $ref: refValue };
6033
6436
  }
6034
6437
  if (isRecord(cursor) && typeof cursor["$ref"] === "string") {
6035
- return resolveRef(cursor["$ref"], doc, depth + 1);
6438
+ return resolveRef2(cursor["$ref"], doc, depth + 1);
6036
6439
  }
6037
6440
  return cursor;
6038
6441
  }
@@ -6041,7 +6444,7 @@ function resolveRefsDeep(value, doc, depth = 0) {
6041
6444
  if (Array.isArray(value)) return value.map((v) => resolveRefsDeep(v, doc, depth + 1));
6042
6445
  if (!isRecord(value)) return value;
6043
6446
  if (typeof value["$ref"] === "string") {
6044
- const resolved = resolveRef(value["$ref"], doc);
6447
+ const resolved = resolveRef2(value["$ref"], doc);
6045
6448
  return resolveRefsDeep(resolved, doc, depth + 1);
6046
6449
  }
6047
6450
  const out = {};
@@ -6491,7 +6894,7 @@ function directoryIsEmpty(projectRoot) {
6491
6894
  }
6492
6895
 
6493
6896
  // src/commands/doctor.ts
6494
- var MIN_NODE_MAJOR = 20;
6897
+ var MIN_NODE_MAJOR = 22;
6495
6898
  function runDoctorEnv() {
6496
6899
  return { exitCode: ExitCode.SUCCESS, envSnapshot: snapshotEnv() };
6497
6900
  }
@@ -6499,7 +6902,7 @@ function runDoctor(opts = {}) {
6499
6902
  const cwd = path12.resolve(opts.cwd ?? process.cwd());
6500
6903
  const checks = [];
6501
6904
  checks.push(checkNode());
6502
- const apiKeyCheck = checkAnthropicApiKey();
6905
+ const apiKeyCheck = checkProviderApiKey(cwd);
6503
6906
  checks.push(apiKeyCheck);
6504
6907
  const qaiosDir = path12.join(cwd, ".qaios");
6505
6908
  const qaiosExists = existsSync(qaiosDir) && safeIsDir(qaiosDir);
@@ -6568,23 +6971,42 @@ function checkNode() {
6568
6971
  detail: `Node ${version} is below the required v${MIN_NODE_MAJOR}.`
6569
6972
  };
6570
6973
  }
6571
- function checkAnthropicApiKey() {
6572
- const key = readAnthropicApiKey();
6974
+ function resolveProviderFromConfig(cwd) {
6975
+ const candidate = path12.join(cwd, ".qaios", "config.yaml");
6976
+ if (!existsSync(candidate)) return { provider: "anthropic" };
6977
+ try {
6978
+ const raw = parse(readFileSync(candidate, "utf-8"));
6979
+ const parsed = QaiosConfig.safeParse(raw ?? { version: 1 });
6980
+ if (parsed.success) {
6981
+ const out = {
6982
+ provider: parsed.data.llm.provider
6983
+ };
6984
+ if (parsed.data.llm.apiKeyEnv !== void 0) out.apiKeyEnv = parsed.data.llm.apiKeyEnv;
6985
+ return out;
6986
+ }
6987
+ } catch {
6988
+ }
6989
+ return { provider: "anthropic" };
6990
+ }
6991
+ function checkProviderApiKey(cwd) {
6992
+ const { provider, apiKeyEnv } = resolveProviderFromConfig(cwd);
6993
+ const envVar = apiKeyEnv ?? (provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY");
6994
+ const key = readProviderApiKey(provider, apiKeyEnv);
6573
6995
  if (key !== void 0) {
6574
- return { name: "ANTHROPIC_API_KEY", status: "ok", detail: "set in environment" };
6996
+ return { name: envVar, status: "ok", detail: `set in environment (provider: ${provider})` };
6575
6997
  }
6576
- const raw = process.env["ANTHROPIC_API_KEY"];
6998
+ const raw = process.env[envVar];
6577
6999
  if (typeof raw === "string" && raw.length > 0) {
6578
7000
  return {
6579
- name: "ANTHROPIC_API_KEY",
7001
+ name: envVar,
6580
7002
  status: "warn",
6581
7003
  detail: "set but blank/whitespace-only \u2014 LLM commands will fail; export a real key"
6582
7004
  };
6583
7005
  }
6584
7006
  return {
6585
- name: "ANTHROPIC_API_KEY",
7007
+ name: envVar,
6586
7008
  status: "warn",
6587
- detail: "not set; export it before running `qaios test` or other LLM-backed commands"
7009
+ detail: `not set; export it before running LLM-backed commands (provider: ${provider})`
6588
7010
  };
6589
7011
  }
6590
7012
  function checkDb(dbPath) {
@@ -6874,12 +7296,20 @@ Use formal techniques (same enumeration as design.web). Prefer:
6874
7296
  Rules:
6875
7297
  - steps describe HTTP interactions: "POST /api/v1/users with body {email, password}",
6876
7298
  not implementation details.
6877
- - oracles describe response shape + status code + observable side effects. The oracle
7299
+ - oracles describe response shape + status code + observable side effects. EVERY oracle
6878
7300
  MUST reference an HTTP status code (e.g. "200", "201", "401", "404") AND/OR a
6879
7301
  response.<property> path so the writer skill can produce a deterministic assertion.
7302
+ An oracle that names neither is invalid \u2014 do not emit it.
6880
7303
  - dataNeeds describe request body categories: "valid signup payload", "payload missing
6881
7304
  email", "payload with email > 254 chars".
6882
7305
  - For each endpoint, generate at minimum 3 scenarios; more if the endpoint is risk-tagged.
7306
+ - COVERAGE FLOOR (non-negotiable): across the suite you MUST include at least one
7307
+ scenario with testType="negative" (e.g. invalid auth / 4xx) AND at least one with
7308
+ testType="boundary" (e.g. a min/max field length or numeric edge). Suites missing
7309
+ either are incomplete.
7310
+ - Every requirement id provided in the input MUST be referenced by at least one
7311
+ scenario's requirementIds \u2014 do not leave a stated requirement uncovered, and do not
7312
+ cite a requirement id that wasn't given.
6883
7313
  - Cross-endpoint dependency tests (e.g., create then read) get their own scenario with
6884
7314
  testType=integration.
6885
7315
 
@@ -6954,19 +7384,26 @@ function checkAuthScenarios(output, endpoints) {
6954
7384
  const authNeeded = endpoints.filter((e) => e.authRequired);
6955
7385
  if (authNeeded.length === 0) return 1;
6956
7386
  const scenarios = output.designSpec.scenarios;
6957
- const covered = authNeeded.filter((ep) => {
7387
+ const isAuthNegative = (s) => {
7388
+ if (s.testType !== "negative") return false;
7389
+ const oracleMentionsAuthCode = /\b401\b|\b403\b|unauthor|forbidden/i.test(s.oracle);
7390
+ const dataNeedsMentionsAuth = s.dataNeeds.some(
7391
+ (d) => /\b(missing|invalid|no|expired|wrong)\b.*(token|auth|key|credential|role)/i.test(d)
7392
+ );
7393
+ return oracleMentionsAuthCode || dataNeedsMentionsAuth;
7394
+ };
7395
+ const anyAuthTest = authNeeded.some((ep) => {
6958
7396
  const re = endpointStepPattern(ep.path, ep.method);
6959
- return scenarios.some((s) => {
6960
- const stepBlob = s.steps.join("\n");
6961
- const matchesEndpoint = re.test(stepBlob);
6962
- const oracleMentions401 = /\b401\b|unauthor/i.test(s.oracle);
6963
- const dataNeedsMentionsAuth = s.dataNeeds.some(
6964
- (d) => /\b(missing|invalid|no|expired)\b.*(token|auth|key|credential)/i.test(d)
6965
- );
6966
- return s.testType === "negative" && matchesEndpoint && (oracleMentions401 || dataNeedsMentionsAuth);
6967
- });
7397
+ return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
7398
+ });
7399
+ const hasGenericAuthTest = scenarios.some(isAuthNegative);
7400
+ if (!anyAuthTest && !hasGenericAuthTest) return 0.5;
7401
+ const explicitlyCovered = authNeeded.filter((ep) => {
7402
+ const re = endpointStepPattern(ep.path, ep.method);
7403
+ return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
6968
7404
  }).length;
6969
- return covered / authNeeded.length;
7405
+ const fraction = explicitlyCovered / authNeeded.length;
7406
+ return Math.max(0.85, fraction);
6970
7407
  }
6971
7408
  var designApiSkill = {
6972
7409
  id: "design.api",
@@ -8203,6 +8640,15 @@ var skills = {
8203
8640
  "audit.a11y": auditA11ySkill
8204
8641
  };
8205
8642
 
8643
+ // src/llm.ts
8644
+ function resolveLlmClient(injected, llmConfig) {
8645
+ if (injected !== void 0) return injected;
8646
+ const opts = {};
8647
+ if (llmConfig?.provider !== void 0) opts.provider = llmConfig.provider;
8648
+ if (llmConfig?.model !== void 0) opts.model = llmConfig.model;
8649
+ return createLlmClient(opts);
8650
+ }
8651
+
8206
8652
  // src/commands/a11y.ts
8207
8653
  function loadConfig(cwd) {
8208
8654
  const candidate = path12.join(cwd, ".qaios", "config.yaml");
@@ -8243,7 +8689,7 @@ async function runA11y(opts) {
8243
8689
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
8244
8690
  const auditLogger = new AuditLogger(storage.db);
8245
8691
  const workflowsRepo = new WorkflowsRepository(storage.db);
8246
- const llm = opts.llm ?? new LlmClient();
8692
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8247
8693
  const writeOut = (line) => {
8248
8694
  if (opts.quiet === true) return;
8249
8695
  if (opts.json === true) process.stdout.write(JSON.stringify({ kind: "log", line }) + "\n");
@@ -8567,7 +9013,7 @@ async function runExplore(opts) {
8567
9013
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
8568
9014
  const auditLogger = new AuditLogger(storage.db);
8569
9015
  const workflowsRepo = new WorkflowsRepository(storage.db);
8570
- const llm = opts.llm ?? new LlmClient();
9016
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8571
9017
  const writeOut = (line) => {
8572
9018
  if (opts.quiet === true) return;
8573
9019
  if (opts.json === true) {
@@ -8978,7 +9424,7 @@ async function runFix(opts) {
8978
9424
  const testResultsRepo = new TestResultsRepository(storage.db);
8979
9425
  const workflowsRepo = new WorkflowsRepository(storage.db);
8980
9426
  const config = loadConfig3(cwd);
8981
- const llm = opts.llm ?? new LlmClient();
9427
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8982
9428
  const mode = opts.mode ?? config?.mode ?? "LITE";
8983
9429
  const writeOut = (line) => {
8984
9430
  if (opts.quiet === true) return;
@@ -9638,18 +10084,14 @@ function runInit(opts = {}) {
9638
10084
  } catch {
9639
10085
  }
9640
10086
  const raw = err.message ?? String(err);
9641
- const isBindingError = /bindings file|was compiled against|NODE_MODULE_VERSION|\.node/i.test(
9642
- raw
9643
- );
9644
- const message = isBindingError ? `Could not load the native SQLite module (better-sqlite3). This usually means the prebuilt binary didn't download or doesn't match your Node version.
9645
- \u2022 Ensure you're on Node 20 LTS (run \`node -v\`).
9646
- \u2022 Reinstall to fetch/rebuild the binary: \`npm rebuild better-sqlite3\` (or reinstall qaios).
9647
- \u2022 Behind a proxy/firewall? The binary is fetched from GitHub releases \u2014 allow that, or install build tools so it can compile.
10087
+ const isNodeTooOld = /node:sqlite|Cannot find module 'node:sqlite'|DatabaseSync/i.test(raw);
10088
+ const message = isNodeTooOld ? `QAIOS needs Node's built-in SQLite (Node 22 LTS or newer).
10089
+ \u2022 Check your version: \`node -v\` \u2014 upgrade to Node 22+ if it's older.
9648
10090
  Original error: ${raw}` : `Failed to initialise workflows.db: ${raw}`;
9649
10091
  return {
9650
10092
  exitCode: err instanceof StorageError ? ExitCode.INTERNAL : ExitCode.TOOL_ERROR,
9651
10093
  error: {
9652
- code: isBindingError ? "qaios.init.sqlite_binding_missing" : err instanceof StorageError ? err.code : "qaios.init.db_failed",
10094
+ code: isNodeTooOld ? "qaios.init.node_too_old" : err instanceof StorageError ? err.code : "qaios.init.db_failed",
9653
10095
  message
9654
10096
  },
9655
10097
  detection
@@ -9758,7 +10200,7 @@ function readRawConfig(configPath) {
9758
10200
  }
9759
10201
  return parsed;
9760
10202
  }
9761
- function readKey(obj, key) {
10203
+ function readKey2(obj, key) {
9762
10204
  const segments = key.split(".");
9763
10205
  let cursor = obj;
9764
10206
  for (const seg of segments) {
@@ -9849,7 +10291,7 @@ function getValue(configPath, opts, writeOut) {
9849
10291
  }
9850
10292
  return { exitCode: ExitCode.SUCCESS, value: raw };
9851
10293
  }
9852
- const value = readKey(raw, opts.key);
10294
+ const value = readKey2(raw, opts.key);
9853
10295
  if (value === void 0) {
9854
10296
  return {
9855
10297
  exitCode: ExitCode.USER_ERROR,
@@ -10240,6 +10682,16 @@ async function testServer(repo, opts, writeOut) {
10240
10682
  if (ownsClient) await client.close();
10241
10683
  }
10242
10684
  }
10685
+ function loadLlmConfig(cwd) {
10686
+ const candidate = path12.join(cwd, ".qaios", "config.yaml");
10687
+ if (!existsSync(candidate)) return void 0;
10688
+ try {
10689
+ const parsed = parse(readFileSync(candidate, "utf-8"));
10690
+ return parsed?.llm;
10691
+ } catch {
10692
+ return void 0;
10693
+ }
10694
+ }
10243
10695
  async function applyDecision(args) {
10244
10696
  const { gate, action, gatesRepo, auditLogger, orchestrator, skipResume } = args;
10245
10697
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -10300,7 +10752,7 @@ async function runReview(opts) {
10300
10752
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10301
10753
  const auditLogger = new AuditLogger(storage.db);
10302
10754
  const gatesRepo = new GatesRepository(storage.db);
10303
- const llm = opts.llm ?? new LlmClient();
10755
+ const llm = resolveLlmClient(opts.llm, loadLlmConfig(cwd));
10304
10756
  try {
10305
10757
  const pending = gatesRepo.listPending(opts.workflowId);
10306
10758
  if (pending.length === 0) {
@@ -10525,8 +10977,8 @@ async function runRun(opts) {
10525
10977
  const ownsStorage = opts.storage === void 0;
10526
10978
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10527
10979
  const auditLogger = new AuditLogger(storage.db);
10528
- const llm = opts.llm ?? new LlmClient();
10529
10980
  const config = loadRunConfig(cwd);
10981
+ const llm = resolveLlmClient(opts.llm, config?.llm);
10530
10982
  const args = {
10531
10983
  cwd,
10532
10984
  noClassify: opts.noClassify === true,
@@ -10825,7 +11277,7 @@ async function runSnapshotCheck(opts) {
10825
11277
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10826
11278
  const auditLogger = new AuditLogger(storage.db);
10827
11279
  const baselineRepo = new VisualBaselinesRepository(storage.db);
10828
- const llm = opts.llm ?? new LlmClient();
11280
+ const llm = resolveLlmClient(opts.llm, config?.llm);
10829
11281
  let baselines = baselineRepo.list();
10830
11282
  if (opts.feature !== void 0) {
10831
11283
  const feat = opts.feature;
@@ -11401,7 +11853,7 @@ ${epSummary}`;
11401
11853
  const ownsStorage = opts.storage === void 0;
11402
11854
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
11403
11855
  const auditLogger = new AuditLogger(storage.db);
11404
- const llm = opts.llm ?? new LlmClient();
11856
+ const llm = resolveLlmClient(opts.llm, config?.llm);
11405
11857
  const gateConfig = {};
11406
11858
  if (opts.nonInteractive === true) gateConfig.nonInteractive = true;
11407
11859
  if (config?.gates?.autoExpireOnTimeout !== void 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qatonic_innovations/qaios",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "AI QA engineer in your terminal — designs, writes, runs, heals, and explores tests for web UI and APIs with audit-grade traceability.",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "self-healing"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=20.0.0"
32
+ "node": ">=22.0.0"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -46,8 +46,8 @@
46
46
  "dependencies": {
47
47
  "@anthropic-ai/sdk": "^0.40.0",
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
- "better-sqlite3": "^11.7.0",
50
49
  "commander": "^12.1.0",
50
+ "openai": "^4.77.0",
51
51
  "ink": "^5.2.1",
52
52
  "pino": "^9.5.0",
53
53
  "pino-pretty": "^11.3.0",
@@ -61,7 +61,6 @@
61
61
  "zod-to-json-schema": "^3.24.0"
62
62
  },
63
63
  "devDependencies": {
64
- "@types/better-sqlite3": "^7.6.12",
65
64
  "@types/pixelmatch": "^5.2.6",
66
65
  "@types/pngjs": "^6.0.5",
67
66
  "@types/react": "^18.3.28",