@qatonic_innovations/qaios 0.1.2 → 0.2.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 +32 -4
  2. package/dist/index.js +416 -52
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -159,8 +159,7 @@ mode: LITE # LITE | FULL | TRUST
159
159
  app:
160
160
  baseUrl: https://staging.myapp.com
161
161
  llm:
162
- provider: anthropic
163
- apiKeyEnv: ANTHROPIC_API_KEY # key is read from env, never stored
162
+ provider: anthropic # anthropic | openai
164
163
  maxLlmCallsPerWorkflow: 15
165
164
  costAlertThresholdUsdCents: 50
166
165
  testing:
@@ -176,6 +175,35 @@ defects:
176
175
 
177
176
  `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
177
 
178
+ ### Choose your LLM provider
179
+
180
+ QAIOS works with **Anthropic** (default) or **OpenAI**. Set the provider in
181
+ `.qaios/config.yaml` and export that provider's key:
182
+
183
+ ```yaml
184
+ # Anthropic (default)
185
+ llm:
186
+ provider: anthropic # reads ANTHROPIC_API_KEY
187
+ ```
188
+
189
+ ```yaml
190
+ # OpenAI
191
+ llm:
192
+ provider: openai # reads OPENAI_API_KEY
193
+ model: gpt-4o # optional; default gpt-4o (try gpt-4o-mini for lower cost)
194
+ ```
195
+
196
+ ```bash
197
+ export OPENAI_API_KEY=sk-...
198
+ qaios doctor # confirms the configured provider's key is reachable
199
+ ```
200
+
201
+ The rest of QAIOS is provider-blind — every command (`test`, `run`, `fix`,
202
+ `explore`, `a11y`, …) works identically on either. Structured output uses each
203
+ provider's native guaranteed-schema mode (Anthropic forced tool-use / OpenAI
204
+ strict function calling), so generated artifacts stay schema-valid. You can
205
+ override the key's env-var name with `llm.apiKeyEnv` if needed.
206
+
179
207
  ### Operating modes
180
208
 
181
209
  - **LITE** (default) — HIGH/CRITICAL risk pauses for review; routine work flows through.
@@ -190,8 +218,8 @@ qaios config set mode TRUST
190
218
 
191
219
  ## Cost & privacy
192
220
 
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.
221
+ - 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.
222
+ - **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
223
 
196
224
  ---
197
225
 
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { createHash } from 'crypto';
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
@@ -2033,18 +2043,29 @@ function computeEntryHash(entry) {
2033
2043
  const { hash: _ignored, ...rest } = entry;
2034
2044
  return sha256Hex2(canonicalize(rest));
2035
2045
  }
2036
- function readAnthropicApiKey() {
2037
- const v = process.env["ANTHROPIC_API_KEY"];
2046
+ function readKey(name) {
2047
+ const v = process.env[name];
2038
2048
  if (typeof v !== "string") return void 0;
2039
2049
  return v.trim().length > 0 ? v : void 0;
2040
2050
  }
2051
+ function readAnthropicApiKey() {
2052
+ return readKey("ANTHROPIC_API_KEY");
2053
+ }
2054
+ function readOpenAiApiKey() {
2055
+ return readKey("OPENAI_API_KEY");
2056
+ }
2057
+ function readProviderApiKey(provider, apiKeyEnvOverride) {
2058
+ if (apiKeyEnvOverride !== void 0 && apiKeyEnvOverride.trim().length > 0) {
2059
+ return readKey(apiKeyEnvOverride);
2060
+ }
2061
+ return provider === "openai" ? readOpenAiApiKey() : readAnthropicApiKey();
2062
+ }
2041
2063
  function snapshotEnv() {
2042
- const k = readAnthropicApiKey();
2064
+ const a = readAnthropicApiKey();
2065
+ const o = readOpenAiApiKey();
2043
2066
  return {
2044
- anthropicApiKey: {
2045
- present: k !== void 0,
2046
- length: k?.length ?? 0
2047
- }
2067
+ anthropicApiKey: { present: a !== void 0, length: a?.length ?? 0 },
2068
+ openaiApiKey: { present: o !== void 0, length: o?.length ?? 0 }
2048
2069
  };
2049
2070
  }
2050
2071
  var PRICING = {
@@ -2079,16 +2100,52 @@ var PRICING = {
2079
2100
  outputPerMTok: 4,
2080
2101
  cacheReadPerMTok: 0.08,
2081
2102
  cacheWritePerMTok: 1
2103
+ },
2104
+ // ── OpenAI (provider: openai) ──────────────────────────────────────────
2105
+ // USD per 1M tokens. OpenAI bills cached input at ~0.5× the input rate;
2106
+ // there is no separate cache-write charge, so cacheWritePerMTok is 0.
2107
+ "gpt-4o": {
2108
+ inputPerMTok: 2.5,
2109
+ outputPerMTok: 10,
2110
+ cacheReadPerMTok: 1.25,
2111
+ cacheWritePerMTok: 0
2112
+ },
2113
+ "gpt-4o-mini": {
2114
+ inputPerMTok: 0.15,
2115
+ outputPerMTok: 0.6,
2116
+ cacheReadPerMTok: 0.075,
2117
+ cacheWritePerMTok: 0
2118
+ },
2119
+ "gpt-4.1": {
2120
+ inputPerMTok: 2,
2121
+ outputPerMTok: 8,
2122
+ cacheReadPerMTok: 0.5,
2123
+ cacheWritePerMTok: 0
2124
+ },
2125
+ "gpt-4.1-mini": {
2126
+ inputPerMTok: 0.4,
2127
+ outputPerMTok: 1.6,
2128
+ cacheReadPerMTok: 0.1,
2129
+ cacheWritePerMTok: 0
2082
2130
  }
2083
2131
  };
2084
2132
  var DEFAULT_PRICING = PRICING["claude-sonnet-4-6"];
2133
+ var _unknownModelWarned = /* @__PURE__ */ new Set();
2085
2134
  function computeCostUsdCents(model, usage) {
2086
- const p = PRICING[model] ?? DEFAULT_PRICING;
2135
+ const p = PRICING[model];
2136
+ if (p === void 0 && !_unknownModelWarned.has(model)) {
2137
+ _unknownModelWarned.add(model);
2138
+ process.stderr.write(
2139
+ `(qaios) warning: no pricing for model "${model}" \u2014 billing at the default rate. Costs in the audit log are approximate for this model.
2140
+ `
2141
+ );
2142
+ }
2143
+ const pricing = p ?? DEFAULT_PRICING;
2087
2144
  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;
2145
+ const inputUsd = tok(usage.inputTokens) / 1e6 * pricing.inputPerMTok;
2146
+ const outputUsd = tok(usage.outputTokens) / 1e6 * pricing.outputPerMTok;
2147
+ const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 * pricing.cacheReadPerMTok;
2148
+ const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 * pricing.cacheWritePerMTok;
2092
2149
  const totalCents = (inputUsd + outputUsd + cacheReadUsd + cacheWriteUsd) * 100;
2093
2150
  return Math.ceil(totalCents);
2094
2151
  }
@@ -2177,6 +2234,260 @@ function mapResponse(response, latencyMs) {
2177
2234
  stopReason: response.stop_reason
2178
2235
  };
2179
2236
  }
2237
+ var UNSUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
2238
+ "minLength",
2239
+ "maxLength",
2240
+ "pattern",
2241
+ "format",
2242
+ "minimum",
2243
+ "maximum",
2244
+ "exclusiveMinimum",
2245
+ "exclusiveMaximum",
2246
+ "multipleOf",
2247
+ "minItems",
2248
+ "maxItems",
2249
+ "uniqueItems",
2250
+ "minProperties",
2251
+ "maxProperties",
2252
+ "default",
2253
+ "$schema",
2254
+ "patternProperties"
2255
+ ]);
2256
+ function isPlainObject(v) {
2257
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2258
+ }
2259
+ function makeNullable(node) {
2260
+ const t = node["type"];
2261
+ if (typeof t === "string") {
2262
+ if (t === "null") return node;
2263
+ return { ...node, type: [t, "null"] };
2264
+ }
2265
+ if (Array.isArray(t)) {
2266
+ return t.includes("null") ? node : { ...node, type: [...t, "null"] };
2267
+ }
2268
+ return { anyOf: [node, { type: "null" }] };
2269
+ }
2270
+ function resolveRef(ref, root) {
2271
+ if (!ref.startsWith("#/")) return void 0;
2272
+ const parts = ref.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
2273
+ let cur = root;
2274
+ for (const part of parts) {
2275
+ if (!isPlainObject(cur) && !Array.isArray(cur)) return void 0;
2276
+ cur = cur[part];
2277
+ }
2278
+ return isPlainObject(cur) ? cur : void 0;
2279
+ }
2280
+ function inlineRefs(node, root, seen) {
2281
+ if (Array.isArray(node)) return node.map((n) => inlineRefs(n, root, seen));
2282
+ if (!isPlainObject(node)) return node;
2283
+ const ref = node["$ref"];
2284
+ if (typeof ref === "string") {
2285
+ if (seen.has(ref)) return { ...node };
2286
+ const target = resolveRef(ref, root);
2287
+ if (target !== void 0) {
2288
+ const next = new Set(seen).add(ref);
2289
+ const { $ref: _drop, ...siblings } = node;
2290
+ const inlined = inlineRefs(target, root, next);
2291
+ return { ...inlined, ...siblings };
2292
+ }
2293
+ }
2294
+ const out = {};
2295
+ for (const [k, v] of Object.entries(node)) out[k] = inlineRefs(v, root, seen);
2296
+ return out;
2297
+ }
2298
+ function openaiStrictify(schema) {
2299
+ if (!isPlainObject(schema)) return schema;
2300
+ return strictifyNode(inlineRefs(schema, schema, /* @__PURE__ */ new Set()));
2301
+ }
2302
+ function strictifyNode(schema) {
2303
+ if (!isPlainObject(schema)) return schema;
2304
+ const out = {};
2305
+ for (const [key, value] of Object.entries(schema)) {
2306
+ if (UNSUPPORTED_KEYWORDS.has(key)) continue;
2307
+ if (key === "properties" && isPlainObject(value)) {
2308
+ const props = {};
2309
+ for (const [propName, propSchema] of Object.entries(value)) {
2310
+ props[propName] = strictifyNode(propSchema);
2311
+ }
2312
+ out["properties"] = props;
2313
+ continue;
2314
+ }
2315
+ if (key === "items") {
2316
+ out["items"] = Array.isArray(value) ? value.map((v) => strictifyNode(v)) : strictifyNode(value);
2317
+ continue;
2318
+ }
2319
+ if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
2320
+ out[key] = value.map((v) => strictifyNode(v));
2321
+ continue;
2322
+ }
2323
+ out[key] = value;
2324
+ }
2325
+ if (out["type"] === "object" && isPlainObject(out["properties"])) {
2326
+ const props = out["properties"];
2327
+ const originalRequired = new Set(
2328
+ Array.isArray(schema["required"]) ? schema["required"] : []
2329
+ );
2330
+ const allKeys = Object.keys(props);
2331
+ for (const k of allKeys) {
2332
+ if (!originalRequired.has(k)) {
2333
+ props[k] = makeNullable(props[k]);
2334
+ }
2335
+ }
2336
+ out["required"] = allKeys;
2337
+ out["additionalProperties"] = false;
2338
+ }
2339
+ return out;
2340
+ }
2341
+ var DEFAULT_OPENAI_MODEL = "gpt-4o";
2342
+ var DEFAULT_MAX_TOKENS2 = 4096;
2343
+ var OpenAiClient = class {
2344
+ client;
2345
+ explicitApiKey;
2346
+ defaultModel;
2347
+ defaultMaxTokens;
2348
+ constructor(opts = {}) {
2349
+ this.client = opts.client ?? null;
2350
+ this.explicitApiKey = opts.apiKey;
2351
+ this.defaultModel = opts.defaultModel ?? DEFAULT_OPENAI_MODEL;
2352
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? DEFAULT_MAX_TOKENS2;
2353
+ }
2354
+ resolveClient() {
2355
+ if (this.client !== null) return this.client;
2356
+ const apiKey = this.explicitApiKey ?? readOpenAiApiKey();
2357
+ if (apiKey === void 0 || apiKey.trim().length === 0) {
2358
+ throw new LlmError({
2359
+ code: "qaios.llm.api_key_missing",
2360
+ 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.'
2361
+ });
2362
+ }
2363
+ const sdk = new OpenAI({ apiKey });
2364
+ this.client = sdk;
2365
+ return this.client;
2366
+ }
2367
+ async call(opts) {
2368
+ const client = this.resolveClient();
2369
+ const model = opts.model ?? this.defaultModel;
2370
+ const params = {
2371
+ model,
2372
+ max_tokens: opts.maxTokens ?? this.defaultMaxTokens,
2373
+ // OpenAI carries the system prompt as a leading system-role message.
2374
+ messages: [
2375
+ { role: "system", content: opts.systemPrompt },
2376
+ { role: "user", content: opts.userPrompt }
2377
+ ]
2378
+ };
2379
+ if (opts.temperature !== void 0) params["temperature"] = opts.temperature;
2380
+ if (opts.tools && opts.tools.length > 0) {
2381
+ params["tools"] = opts.tools.map((t) => ({
2382
+ type: "function",
2383
+ function: {
2384
+ name: t.name,
2385
+ description: t.description,
2386
+ parameters: openaiStrictify(t.input_schema),
2387
+ strict: true
2388
+ }
2389
+ }));
2390
+ }
2391
+ if (opts.toolChoice !== void 0) {
2392
+ params["tool_choice"] = mapToolChoice(opts.toolChoice);
2393
+ }
2394
+ const reqOpts = {};
2395
+ if (opts.signal !== void 0) reqOpts.signal = opts.signal;
2396
+ const start = Date.now();
2397
+ const response = await client.chat.completions.create(params, reqOpts);
2398
+ const latencyMs = Date.now() - start;
2399
+ return mapResponse2(response, latencyMs);
2400
+ }
2401
+ };
2402
+ function mapToolChoice(choice) {
2403
+ switch (choice.type) {
2404
+ case "auto":
2405
+ return "auto";
2406
+ case "any":
2407
+ return "required";
2408
+ case "tool":
2409
+ return { type: "function", function: { name: choice.name } };
2410
+ }
2411
+ }
2412
+ function mapFinishReason(reason) {
2413
+ switch (reason) {
2414
+ case "stop":
2415
+ return "end_turn";
2416
+ case "tool_calls":
2417
+ case "function_call":
2418
+ return "tool_use";
2419
+ case "length":
2420
+ return "max_tokens";
2421
+ default:
2422
+ return reason;
2423
+ }
2424
+ }
2425
+ function mapResponse2(response, latencyMs) {
2426
+ const choice = response.choices[0];
2427
+ const message = choice?.message;
2428
+ const output = message?.content ?? "";
2429
+ const toolCalls = (message?.tool_calls ?? []).map((tc) => ({
2430
+ id: tc.id,
2431
+ name: tc.function.name,
2432
+ // OpenAI returns function arguments as a JSON STRING — parse to match the
2433
+ // parsed-object shape Anthropic's tool_use blocks give us. A malformed
2434
+ // payload surfaces as an LlmError rather than a silent {}.
2435
+ input: parseArguments(tc.function.arguments, tc.function.name)
2436
+ }));
2437
+ const promptTokens = response.usage?.prompt_tokens ?? 0;
2438
+ const cachedTokens = response.usage?.prompt_tokens_details?.cached_tokens ?? 0;
2439
+ const usage = {
2440
+ // OpenAI's prompt_tokens INCLUDES cached tokens; bill the uncached portion
2441
+ // at the input rate and the cached portion at the cache-read rate.
2442
+ inputTokens: Math.max(0, promptTokens - cachedTokens),
2443
+ outputTokens: response.usage?.completion_tokens ?? 0
2444
+ };
2445
+ if (cachedTokens > 0) usage.cacheReadTokens = cachedTokens;
2446
+ return {
2447
+ output,
2448
+ toolCalls,
2449
+ usage,
2450
+ model: response.model,
2451
+ latencyMs,
2452
+ costUsdCents: computeCostUsdCents(response.model, usage),
2453
+ stopReason: mapFinishReason(choice?.finish_reason ?? null)
2454
+ };
2455
+ }
2456
+ function parseArguments(raw, toolName) {
2457
+ let parsed;
2458
+ try {
2459
+ parsed = JSON.parse(raw);
2460
+ } catch (err) {
2461
+ throw new LlmError({
2462
+ code: "qaios.llm.malformed_tool_arguments",
2463
+ message: `OpenAI returned non-JSON arguments for tool "${toolName}".`,
2464
+ cause: err
2465
+ });
2466
+ }
2467
+ return stripNulls(parsed);
2468
+ }
2469
+ function stripNulls(value) {
2470
+ if (Array.isArray(value)) return value.map(stripNulls);
2471
+ if (value !== null && typeof value === "object") {
2472
+ const out = {};
2473
+ for (const [k, v] of Object.entries(value)) {
2474
+ if (v === null) continue;
2475
+ out[k] = stripNulls(v);
2476
+ }
2477
+ return out;
2478
+ }
2479
+ return value;
2480
+ }
2481
+ function defaultModelFor(provider) {
2482
+ return provider === "openai" ? DEFAULT_OPENAI_MODEL : DEFAULT_LLM_MODEL;
2483
+ }
2484
+ function createLlmClient(opts = {}) {
2485
+ if (opts.client !== void 0) return opts.client;
2486
+ const provider = opts.provider ?? "anthropic";
2487
+ const defaultModel = opts.model ?? defaultModelFor(provider);
2488
+ const clientOpts = opts.apiKey !== void 0 ? { apiKey: opts.apiKey, defaultModel } : { defaultModel };
2489
+ return provider === "openai" ? new OpenAiClient(clientOpts) : new LlmClient(clientOpts);
2490
+ }
2180
2491
  var SkillError = class extends Error {
2181
2492
  code;
2182
2493
  skillId;
@@ -6021,7 +6332,7 @@ function parseOpenApi(specPath) {
6021
6332
  function isRecord(v) {
6022
6333
  return typeof v === "object" && v !== null && !Array.isArray(v);
6023
6334
  }
6024
- function resolveRef(refValue, doc, depth = 0) {
6335
+ function resolveRef2(refValue, doc, depth = 0) {
6025
6336
  if (depth > 4) return { $ref: refValue };
6026
6337
  if (!refValue.startsWith("#/")) return { $ref: refValue };
6027
6338
  const segments = refValue.slice(2).split("/").map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"));
@@ -6032,7 +6343,7 @@ function resolveRef(refValue, doc, depth = 0) {
6032
6343
  if (cursor === void 0) return { $ref: refValue };
6033
6344
  }
6034
6345
  if (isRecord(cursor) && typeof cursor["$ref"] === "string") {
6035
- return resolveRef(cursor["$ref"], doc, depth + 1);
6346
+ return resolveRef2(cursor["$ref"], doc, depth + 1);
6036
6347
  }
6037
6348
  return cursor;
6038
6349
  }
@@ -6041,7 +6352,7 @@ function resolveRefsDeep(value, doc, depth = 0) {
6041
6352
  if (Array.isArray(value)) return value.map((v) => resolveRefsDeep(v, doc, depth + 1));
6042
6353
  if (!isRecord(value)) return value;
6043
6354
  if (typeof value["$ref"] === "string") {
6044
- const resolved = resolveRef(value["$ref"], doc);
6355
+ const resolved = resolveRef2(value["$ref"], doc);
6045
6356
  return resolveRefsDeep(resolved, doc, depth + 1);
6046
6357
  }
6047
6358
  const out = {};
@@ -6499,7 +6810,7 @@ function runDoctor(opts = {}) {
6499
6810
  const cwd = path12.resolve(opts.cwd ?? process.cwd());
6500
6811
  const checks = [];
6501
6812
  checks.push(checkNode());
6502
- const apiKeyCheck = checkAnthropicApiKey();
6813
+ const apiKeyCheck = checkProviderApiKey(cwd);
6503
6814
  checks.push(apiKeyCheck);
6504
6815
  const qaiosDir = path12.join(cwd, ".qaios");
6505
6816
  const qaiosExists = existsSync(qaiosDir) && safeIsDir(qaiosDir);
@@ -6568,23 +6879,42 @@ function checkNode() {
6568
6879
  detail: `Node ${version} is below the required v${MIN_NODE_MAJOR}.`
6569
6880
  };
6570
6881
  }
6571
- function checkAnthropicApiKey() {
6572
- const key = readAnthropicApiKey();
6882
+ function resolveProviderFromConfig(cwd) {
6883
+ const candidate = path12.join(cwd, ".qaios", "config.yaml");
6884
+ if (!existsSync(candidate)) return { provider: "anthropic" };
6885
+ try {
6886
+ const raw = parse(readFileSync(candidate, "utf-8"));
6887
+ const parsed = QaiosConfig.safeParse(raw ?? { version: 1 });
6888
+ if (parsed.success) {
6889
+ const out = {
6890
+ provider: parsed.data.llm.provider
6891
+ };
6892
+ if (parsed.data.llm.apiKeyEnv !== void 0) out.apiKeyEnv = parsed.data.llm.apiKeyEnv;
6893
+ return out;
6894
+ }
6895
+ } catch {
6896
+ }
6897
+ return { provider: "anthropic" };
6898
+ }
6899
+ function checkProviderApiKey(cwd) {
6900
+ const { provider, apiKeyEnv } = resolveProviderFromConfig(cwd);
6901
+ const envVar = apiKeyEnv ?? (provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY");
6902
+ const key = readProviderApiKey(provider, apiKeyEnv);
6573
6903
  if (key !== void 0) {
6574
- return { name: "ANTHROPIC_API_KEY", status: "ok", detail: "set in environment" };
6904
+ return { name: envVar, status: "ok", detail: `set in environment (provider: ${provider})` };
6575
6905
  }
6576
- const raw = process.env["ANTHROPIC_API_KEY"];
6906
+ const raw = process.env[envVar];
6577
6907
  if (typeof raw === "string" && raw.length > 0) {
6578
6908
  return {
6579
- name: "ANTHROPIC_API_KEY",
6909
+ name: envVar,
6580
6910
  status: "warn",
6581
6911
  detail: "set but blank/whitespace-only \u2014 LLM commands will fail; export a real key"
6582
6912
  };
6583
6913
  }
6584
6914
  return {
6585
- name: "ANTHROPIC_API_KEY",
6915
+ name: envVar,
6586
6916
  status: "warn",
6587
- detail: "not set; export it before running `qaios test` or other LLM-backed commands"
6917
+ detail: `not set; export it before running LLM-backed commands (provider: ${provider})`
6588
6918
  };
6589
6919
  }
6590
6920
  function checkDb(dbPath) {
@@ -6874,12 +7204,20 @@ Use formal techniques (same enumeration as design.web). Prefer:
6874
7204
  Rules:
6875
7205
  - steps describe HTTP interactions: "POST /api/v1/users with body {email, password}",
6876
7206
  not implementation details.
6877
- - oracles describe response shape + status code + observable side effects. The oracle
7207
+ - oracles describe response shape + status code + observable side effects. EVERY oracle
6878
7208
  MUST reference an HTTP status code (e.g. "200", "201", "401", "404") AND/OR a
6879
7209
  response.<property> path so the writer skill can produce a deterministic assertion.
7210
+ An oracle that names neither is invalid \u2014 do not emit it.
6880
7211
  - dataNeeds describe request body categories: "valid signup payload", "payload missing
6881
7212
  email", "payload with email > 254 chars".
6882
7213
  - For each endpoint, generate at minimum 3 scenarios; more if the endpoint is risk-tagged.
7214
+ - COVERAGE FLOOR (non-negotiable): across the suite you MUST include at least one
7215
+ scenario with testType="negative" (e.g. invalid auth / 4xx) AND at least one with
7216
+ testType="boundary" (e.g. a min/max field length or numeric edge). Suites missing
7217
+ either are incomplete.
7218
+ - Every requirement id provided in the input MUST be referenced by at least one
7219
+ scenario's requirementIds \u2014 do not leave a stated requirement uncovered, and do not
7220
+ cite a requirement id that wasn't given.
6883
7221
  - Cross-endpoint dependency tests (e.g., create then read) get their own scenario with
6884
7222
  testType=integration.
6885
7223
 
@@ -6954,19 +7292,26 @@ function checkAuthScenarios(output, endpoints) {
6954
7292
  const authNeeded = endpoints.filter((e) => e.authRequired);
6955
7293
  if (authNeeded.length === 0) return 1;
6956
7294
  const scenarios = output.designSpec.scenarios;
6957
- const covered = authNeeded.filter((ep) => {
7295
+ const isAuthNegative = (s) => {
7296
+ if (s.testType !== "negative") return false;
7297
+ const oracleMentionsAuthCode = /\b401\b|\b403\b|unauthor|forbidden/i.test(s.oracle);
7298
+ const dataNeedsMentionsAuth = s.dataNeeds.some(
7299
+ (d) => /\b(missing|invalid|no|expired|wrong)\b.*(token|auth|key|credential|role)/i.test(d)
7300
+ );
7301
+ return oracleMentionsAuthCode || dataNeedsMentionsAuth;
7302
+ };
7303
+ const anyAuthTest = authNeeded.some((ep) => {
6958
7304
  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
- });
7305
+ return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
7306
+ });
7307
+ const hasGenericAuthTest = scenarios.some(isAuthNegative);
7308
+ if (!anyAuthTest && !hasGenericAuthTest) return 0.5;
7309
+ const explicitlyCovered = authNeeded.filter((ep) => {
7310
+ const re = endpointStepPattern(ep.path, ep.method);
7311
+ return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
6968
7312
  }).length;
6969
- return covered / authNeeded.length;
7313
+ const fraction = explicitlyCovered / authNeeded.length;
7314
+ return Math.max(0.85, fraction);
6970
7315
  }
6971
7316
  var designApiSkill = {
6972
7317
  id: "design.api",
@@ -8203,6 +8548,15 @@ var skills = {
8203
8548
  "audit.a11y": auditA11ySkill
8204
8549
  };
8205
8550
 
8551
+ // src/llm.ts
8552
+ function resolveLlmClient(injected, llmConfig) {
8553
+ if (injected !== void 0) return injected;
8554
+ const opts = {};
8555
+ if (llmConfig?.provider !== void 0) opts.provider = llmConfig.provider;
8556
+ if (llmConfig?.model !== void 0) opts.model = llmConfig.model;
8557
+ return createLlmClient(opts);
8558
+ }
8559
+
8206
8560
  // src/commands/a11y.ts
8207
8561
  function loadConfig(cwd) {
8208
8562
  const candidate = path12.join(cwd, ".qaios", "config.yaml");
@@ -8243,7 +8597,7 @@ async function runA11y(opts) {
8243
8597
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
8244
8598
  const auditLogger = new AuditLogger(storage.db);
8245
8599
  const workflowsRepo = new WorkflowsRepository(storage.db);
8246
- const llm = opts.llm ?? new LlmClient();
8600
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8247
8601
  const writeOut = (line) => {
8248
8602
  if (opts.quiet === true) return;
8249
8603
  if (opts.json === true) process.stdout.write(JSON.stringify({ kind: "log", line }) + "\n");
@@ -8567,7 +8921,7 @@ async function runExplore(opts) {
8567
8921
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
8568
8922
  const auditLogger = new AuditLogger(storage.db);
8569
8923
  const workflowsRepo = new WorkflowsRepository(storage.db);
8570
- const llm = opts.llm ?? new LlmClient();
8924
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8571
8925
  const writeOut = (line) => {
8572
8926
  if (opts.quiet === true) return;
8573
8927
  if (opts.json === true) {
@@ -8978,7 +9332,7 @@ async function runFix(opts) {
8978
9332
  const testResultsRepo = new TestResultsRepository(storage.db);
8979
9333
  const workflowsRepo = new WorkflowsRepository(storage.db);
8980
9334
  const config = loadConfig3(cwd);
8981
- const llm = opts.llm ?? new LlmClient();
9335
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8982
9336
  const mode = opts.mode ?? config?.mode ?? "LITE";
8983
9337
  const writeOut = (line) => {
8984
9338
  if (opts.quiet === true) return;
@@ -9758,7 +10112,7 @@ function readRawConfig(configPath) {
9758
10112
  }
9759
10113
  return parsed;
9760
10114
  }
9761
- function readKey(obj, key) {
10115
+ function readKey2(obj, key) {
9762
10116
  const segments = key.split(".");
9763
10117
  let cursor = obj;
9764
10118
  for (const seg of segments) {
@@ -9849,7 +10203,7 @@ function getValue(configPath, opts, writeOut) {
9849
10203
  }
9850
10204
  return { exitCode: ExitCode.SUCCESS, value: raw };
9851
10205
  }
9852
- const value = readKey(raw, opts.key);
10206
+ const value = readKey2(raw, opts.key);
9853
10207
  if (value === void 0) {
9854
10208
  return {
9855
10209
  exitCode: ExitCode.USER_ERROR,
@@ -10240,6 +10594,16 @@ async function testServer(repo, opts, writeOut) {
10240
10594
  if (ownsClient) await client.close();
10241
10595
  }
10242
10596
  }
10597
+ function loadLlmConfig(cwd) {
10598
+ const candidate = path12.join(cwd, ".qaios", "config.yaml");
10599
+ if (!existsSync(candidate)) return void 0;
10600
+ try {
10601
+ const parsed = parse(readFileSync(candidate, "utf-8"));
10602
+ return parsed?.llm;
10603
+ } catch {
10604
+ return void 0;
10605
+ }
10606
+ }
10243
10607
  async function applyDecision(args) {
10244
10608
  const { gate, action, gatesRepo, auditLogger, orchestrator, skipResume } = args;
10245
10609
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -10300,7 +10664,7 @@ async function runReview(opts) {
10300
10664
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10301
10665
  const auditLogger = new AuditLogger(storage.db);
10302
10666
  const gatesRepo = new GatesRepository(storage.db);
10303
- const llm = opts.llm ?? new LlmClient();
10667
+ const llm = resolveLlmClient(opts.llm, loadLlmConfig(cwd));
10304
10668
  try {
10305
10669
  const pending = gatesRepo.listPending(opts.workflowId);
10306
10670
  if (pending.length === 0) {
@@ -10525,8 +10889,8 @@ async function runRun(opts) {
10525
10889
  const ownsStorage = opts.storage === void 0;
10526
10890
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10527
10891
  const auditLogger = new AuditLogger(storage.db);
10528
- const llm = opts.llm ?? new LlmClient();
10529
10892
  const config = loadRunConfig(cwd);
10893
+ const llm = resolveLlmClient(opts.llm, config?.llm);
10530
10894
  const args = {
10531
10895
  cwd,
10532
10896
  noClassify: opts.noClassify === true,
@@ -10825,7 +11189,7 @@ async function runSnapshotCheck(opts) {
10825
11189
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10826
11190
  const auditLogger = new AuditLogger(storage.db);
10827
11191
  const baselineRepo = new VisualBaselinesRepository(storage.db);
10828
- const llm = opts.llm ?? new LlmClient();
11192
+ const llm = resolveLlmClient(opts.llm, config?.llm);
10829
11193
  let baselines = baselineRepo.list();
10830
11194
  if (opts.feature !== void 0) {
10831
11195
  const feat = opts.feature;
@@ -11401,7 +11765,7 @@ ${epSummary}`;
11401
11765
  const ownsStorage = opts.storage === void 0;
11402
11766
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
11403
11767
  const auditLogger = new AuditLogger(storage.db);
11404
- const llm = opts.llm ?? new LlmClient();
11768
+ const llm = resolveLlmClient(opts.llm, config?.llm);
11405
11769
  const gateConfig = {};
11406
11770
  if (opts.nonInteractive === true) gateConfig.nonInteractive = true;
11407
11771
  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.2.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",
@@ -48,6 +48,7 @@
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "better-sqlite3": "^11.7.0",
50
50
  "commander": "^12.1.0",
51
+ "openai": "^4.77.0",
51
52
  "ink": "^5.2.1",
52
53
  "pino": "^9.5.0",
53
54
  "pino-pretty": "^11.3.0",