@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.
- package/README.md +32 -4
- package/dist/index.js +416 -52
- 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
|
-
-
|
|
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
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
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
|
|
2037
|
-
const v = process.env[
|
|
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
|
|
2064
|
+
const a = readAnthropicApiKey();
|
|
2065
|
+
const o = readOpenAiApiKey();
|
|
2043
2066
|
return {
|
|
2044
|
-
anthropicApiKey: {
|
|
2045
|
-
|
|
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]
|
|
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 *
|
|
2089
|
-
const outputUsd = tok(usage.outputTokens) / 1e6 *
|
|
2090
|
-
const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 *
|
|
2091
|
-
const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 *
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
6572
|
-
const
|
|
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:
|
|
6904
|
+
return { name: envVar, status: "ok", detail: `set in environment (provider: ${provider})` };
|
|
6575
6905
|
}
|
|
6576
|
-
const raw = process.env[
|
|
6906
|
+
const raw = process.env[envVar];
|
|
6577
6907
|
if (typeof raw === "string" && raw.length > 0) {
|
|
6578
6908
|
return {
|
|
6579
|
-
name:
|
|
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:
|
|
6915
|
+
name: envVar,
|
|
6586
6916
|
status: "warn",
|
|
6587
|
-
detail:
|
|
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.
|
|
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
|
|
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
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|