@analitikisler/ai-tag-debugger 0.15.0 → 0.17.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/src/pool.js ADDED
@@ -0,0 +1,34 @@
1
+ // Runs independent jobs a few at a time. Each journey and consent scenario opens its own browser,
2
+ // so an audit with several journeys and viewports finishes sooner when they run side by side.
3
+ export const MAX_CONCURRENCY = 8;
4
+
5
+ /** The number of jobs to run at once, from --concurrency, the config or the MCP tool. Default 1, at most 8. */
6
+ export function parseConcurrency(value) {
7
+ if (value === undefined || value === null || value === "") return 1;
8
+ // Only a number or a numeric string counts: true would otherwise pass as 1.
9
+ const n = typeof value === "number" || typeof value === "string" ? Number(value) : NaN;
10
+ if (!(Number.isInteger(n) && n >= 1 && n <= MAX_CONCURRENCY)) throw new Error(`"concurrency" must be a whole number from 1 to ${MAX_CONCURRENCY} (--concurrency <n>, or "concurrency" in the config).`);
11
+ return n;
12
+ }
13
+
14
+ /**
15
+ * Runs worker(job, index) for every job, at most `limit` at a time, and returns the results in the jobs'
16
+ * order, not the order they finished in. A worker that throws rejects the pool, so callers keep each
17
+ * job's errors inside the worker. With a limit of 1 the jobs run strictly one after another.
18
+ */
19
+ export async function runPool(jobs, limit, worker) {
20
+ const results = new Array(jobs.length);
21
+ let next = 0;
22
+ // Each lane takes the next job as soon as its own is done, so the lanes stay busy whatever the job lengths.
23
+ const lane = async () => {
24
+ while (next < jobs.length) {
25
+ const i = next++;
26
+ results[i] = await worker(jobs[i], i);
27
+ }
28
+ };
29
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, jobs.length)) }, lane));
30
+ return results;
31
+ }
32
+
33
+ /** A log whose every line starts with a tag, so the lines of jobs running side by side can be told apart. */
34
+ export const taggedLog = (log, tag) => (message) => log(String(message).replace(/^(?=.)/gm, `${tag} `));
package/src/providers.js CHANGED
@@ -42,9 +42,10 @@ function hasApiKey(env) {
42
42
  }
43
43
 
44
44
  /**
45
- * @param {{ name?: string, model?: string, baseUrl?: string, client?: any }} opts
45
+ * @param {{ name?: string, model?: string, baseUrl?: string, client?: any, log?: (msg: string) => void, retry?: { attempts?: number, baseDelayMs?: number, sleep?: (ms: number) => Promise<void> } }} opts
46
46
  * baseUrl: for openai-compatible, e.g. https://api.deepseek.com or http://localhost:11434/v1.
47
47
  * client: an already constructed SDK client (used by tests).
48
+ * log: gets one line per retried model request. retry: overrides for withRetry (tests).
48
49
  */
49
50
  export async function createProvider(opts = {}, env = process.env) {
50
51
  // A bare client (tests, embedding) is an Anthropic client unless named otherwise.
@@ -66,39 +67,112 @@ async function buildProvider(name, opts, env) {
66
67
  if (!model) throw new Error("openai-compatible needs a model id: --model or OPENAI_COMPATIBLE_MODEL (\"cheap\" has no meaning here, since the API's models are unknown).");
67
68
  // Local servers such as Ollama need no key, but the SDK wants a non-empty one.
68
69
  const apiKey = env.OPENAI_COMPATIBLE_API_KEY || "not-needed";
69
- const client = opts.client ?? new (await import("openai")).default({ baseURL, apiKey });
70
+ const client = opts.client ?? new (await import("openai")).default(sdkOptions({ baseURL, apiKey }));
70
71
  return openaiProvider(client, model, { compatible: true, label: new URL(baseURL).host });
71
72
  }
72
73
  if (name === "vercel") {
73
- const client = opts.client ?? new (await import("openai")).default({ baseURL: VERCEL_GATEWAY, apiKey: env.AI_GATEWAY_API_KEY });
74
+ const client = opts.client ?? new (await import("openai")).default(sdkOptions({ baseURL: VERCEL_GATEWAY, apiKey: env.AI_GATEWAY_API_KEY }));
74
75
  return { ...openaiProvider(client, pickModel("vercel", opts.model), { compatible: true }), name: "vercel" };
75
76
  }
76
77
  if (name === "cloudflare") {
77
78
  const account = env.CLOUDFLARE_ACCOUNT_ID;
78
79
  if (!opts.client && !(account && env.CLOUDFLARE_API_TOKEN)) throw new Error("Cloudflare Workers AI needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.");
79
- const client = opts.client ?? new (await import("openai")).default({ baseURL: `${CLOUDFLARE_API}/${account}/ai/v1`, apiKey: env.CLOUDFLARE_API_TOKEN });
80
+ const client = opts.client ?? new (await import("openai")).default(sdkOptions({ baseURL: `${CLOUDFLARE_API}/${account}/ai/v1`, apiKey: env.CLOUDFLARE_API_TOKEN }));
80
81
  return { ...openaiProvider(client, pickModel("cloudflare", opts.model), { compatible: true }), name: "cloudflare" };
81
82
  }
82
83
  const model = pickModel(name, opts.model);
83
84
  const gateway = gatewayOptions(name, env);
84
- const provider = await builtInProvider(name, model, gateway, opts.client, env);
85
+ const provider = await builtInProvider(name, model, gateway, opts, env);
85
86
  // Shown in the "Using ..." line so a run says whether its calls went through the gateway.
86
87
  if (gateway.baseURL && !opts.client) provider.via = "Cloudflare AI Gateway";
87
88
  return provider;
88
89
  }
89
90
 
90
- async function builtInProvider(name, model, gateway, client, env) {
91
- if (name === "anthropic") return anthropicProvider(client ?? new (await import("@anthropic-ai/sdk")).default(gateway), model);
92
- if (name === "openai") return openaiProvider(client ?? new (await import("openai")).default(gateway), model);
91
+ async function builtInProvider(name, model, gateway, { client, log, retry }, env) {
92
+ if (name === "anthropic") return anthropicProvider(client ?? new (await import("@anthropic-ai/sdk")).default(sdkOptions(gateway)), model);
93
+ if (name === "openai") return openaiProvider(client ?? new (await import("openai")).default(sdkOptions(gateway)), model);
93
94
  const { GoogleGenAI } = await import("@google/genai");
94
95
  const apiKey = env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY;
96
+ // The Gemini SDK retries only when asked (httpOptions.retryOptions) and says nothing when it does,
97
+ // so its calls go through withRetry below instead, which logs each retry.
95
98
  const httpOptions = gateway.baseURL ? { httpOptions: { baseUrl: gateway.baseURL, headers: gateway.defaultHeaders } } : {};
96
- return geminiProvider(client ?? new GoogleGenAI({ ...(apiKey ? { apiKey } : {}), ...httpOptions }), model);
99
+ return geminiProvider(client ?? new GoogleGenAI({ ...(apiKey ? { apiKey } : {}), ...httpOptions }), model, { ...retry, log });
97
100
  }
98
101
 
99
102
  const VERCEL_GATEWAY = "https://ai-gateway.vercel.sh/v1";
100
103
  const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4/accounts";
101
104
 
105
+ // --- Retries -----------------------------------------------------------------
106
+
107
+ /** How many times a failed model request is tried in all (the first try included). */
108
+ export const MODEL_ATTEMPTS = 3;
109
+ /** The wait before the first retry; each later one waits twice as long, give or take some jitter. */
110
+ export const RETRY_BASE_DELAY_MS = 2000;
111
+ /** The longest wait a retry-after header is allowed to ask for, so a run never hangs on one request. */
112
+ const RETRY_AFTER_MAX_MS = 60_000;
113
+ /** How long one request may take before it counts as failed. The SDKs' own 10 minutes would outlast the step. */
114
+ const MODEL_TIMEOUT_MS = 300_000;
115
+
116
+ /**
117
+ * The Anthropic and OpenAI SDKs retry on their own (408, 409, 429, 5xx, connection errors, with backoff
118
+ * and retry-after), so they only get the attempt count and a timeout; withRetry is not stacked on them.
119
+ */
120
+ function sdkOptions(extra = {}) {
121
+ // maxRetries counts retries, not attempts, so this gives the SDKs the same total as withRetry.
122
+ return { maxRetries: MODEL_ATTEMPTS - 1, timeout: MODEL_TIMEOUT_MS, ...extra };
123
+ }
124
+
125
+ const TRANSIENT_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504, 529]);
126
+ const TRANSIENT_MESSAGES = /overloaded|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|EPIPE|fetch failed|socket hang up|network error|terminated/i;
127
+
128
+ /**
129
+ * True for a failure a second try can get past: a rate limit, an overloaded or restarting API, a dropped
130
+ * connection. Bad requests, bad keys, missing models, refusals and schema errors fail at once.
131
+ */
132
+ export function isTransientModelError(err) {
133
+ const status = err?.status ?? err?.statusCode ?? err?.response?.status;
134
+ if (Number.isInteger(status)) return TRANSIENT_STATUSES.has(status);
135
+ return TRANSIENT_MESSAGES.test([err?.code, err?.message, err?.cause?.code, err?.cause?.message].filter(Boolean).join(" "));
136
+ }
137
+
138
+ /** The wait a retry-after header asks for, in milliseconds (seconds or an HTTP date), or null when there is none. */
139
+ export function retryAfterMs(err, now = Date.now()) {
140
+ const headers = err?.headers ?? err?.response?.headers;
141
+ const header = (name) => (typeof headers?.get === "function" ? headers.get(name) : headers?.[name]) ?? null;
142
+ const ms = Number(header("retry-after-ms"));
143
+ if (ms > 0) return Math.min(ms, RETRY_AFTER_MAX_MS);
144
+ const after = header("retry-after");
145
+ if (after == null || after === "") return null;
146
+ const seconds = Number(after);
147
+ const wait = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(after) - now;
148
+ return wait > 0 ? Math.min(wait, RETRY_AFTER_MAX_MS) : null;
149
+ }
150
+
151
+ /** What the log line says went wrong: the HTTP status when there is one, else the error code or message. */
152
+ const describeError = (err) => err?.status ?? err?.statusCode ?? err?.response?.status ?? err?.code ?? err?.cause?.code ?? String(err?.message ?? err).split("\n")[0].slice(0, 80);
153
+
154
+ /**
155
+ * Runs fn, trying again after a transient failure with exponential backoff (2s, 4s, 8s... with jitter),
156
+ * or after the wait a retry-after header asks for. Any other failure, or the last one, is thrown as is.
157
+ * @param {() => Promise<any>} fn
158
+ * @param {{ attempts?: number, baseDelayMs?: number, isTransient?: (err: any) => boolean, log?: (msg: string) => void, sleep?: (ms: number) => Promise<void>, random?: () => number }} opts
159
+ * sleep and random are injectable so tests run without waiting.
160
+ */
161
+ export async function withRetry(fn, { attempts = MODEL_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS, isTransient = isTransientModelError, log = () => {}, sleep = wait, random = Math.random } = {}) {
162
+ for (let attempt = 1; ; attempt++) {
163
+ try {
164
+ return await fn();
165
+ } catch (err) {
166
+ if (attempt >= attempts || !isTransient(err)) throw err;
167
+ const delay = retryAfterMs(err) ?? Math.round(baseDelayMs * 2 ** (attempt - 1) * (1 + random() * 0.25));
168
+ log(` Model request failed (${describeError(err)}); retrying in ${Math.round(delay / 1000)}s (attempt ${attempt + 1} of ${attempts})`);
169
+ await sleep(delay);
170
+ }
171
+ }
172
+ }
173
+
174
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
175
+
102
176
  /** An openai-compatible API has no known cheap model, so `cheap` falls back to OPENAI_COMPATIBLE_MODEL. */
103
177
  function compatibleModel(model, env) {
104
178
  return (model === "cheap" ? undefined : model) ?? env.OPENAI_COMPATIBLE_MODEL;
@@ -160,6 +234,7 @@ function anthropicProvider(client, model) {
160
234
  return {
161
235
  name: "anthropic",
162
236
  model,
237
+ client, // exposed so the retry settings can be checked
163
238
  conversation({ system, tools, firstMessage }) {
164
239
  const messages = [{ role: "user", content: firstMessage }];
165
240
  const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.schema }));
@@ -217,6 +292,7 @@ function openaiProvider(client, model, { compatible = false, label } = {}) {
217
292
  return {
218
293
  name: compatible ? `openai-compatible (${label})` : "openai",
219
294
  model,
295
+ client, // exposed so the retry settings can be checked
220
296
  conversation({ system, tools, firstMessage }) {
221
297
  const messages = [
222
298
  { role: "system", content: system },
@@ -275,10 +351,12 @@ function openaiProvider(client, model, { compatible = false, label } = {}) {
275
351
 
276
352
  // --- Google Gemini -----------------------------------------------------------
277
353
 
278
- function geminiProvider(ai, model) {
354
+ function geminiProvider(ai, model, retry = {}) {
355
+ const generate = (params) => withRetry(() => ai.models.generateContent(params), retry);
279
356
  return {
280
357
  name: "gemini",
281
358
  model,
359
+ client: ai,
282
360
  conversation({ system, tools, firstMessage }) {
283
361
  const contents = [{ role: "user", parts: [{ text: firstMessage }] }];
284
362
  const config = {
@@ -288,7 +366,7 @@ function geminiProvider(ai, model) {
288
366
  let pendingNames = new Map();
289
367
  return {
290
368
  async next() {
291
- const response = await ai.models.generateContent({ model, contents, config });
369
+ const response = await generate({ model, contents, config });
292
370
  const candidate = response.candidates?.[0];
293
371
  if (!candidate?.content) return { toolCalls: [], stop: candidate?.finishReason === "MAX_TOKENS" ? "max_tokens" : "refusal" };
294
372
  contents.push(candidate.content);
@@ -322,7 +400,7 @@ function geminiProvider(ai, model) {
322
400
  };
323
401
  },
324
402
  async json({ system, texts, schema }) {
325
- const response = await ai.models.generateContent({
403
+ const response = await generate({
326
404
  model,
327
405
  contents: [{ role: "user", parts: texts.map((text) => ({ text })) }],
328
406
  config: { systemInstruction: system, responseMimeType: "application/json", responseJsonSchema: schema },
package/src/replay.js CHANGED
@@ -5,12 +5,15 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { harName } from "./agent.js";
7
7
  import { runAudit } from "./audit.js";
8
+ import { listRunIds, runsDir } from "./workspace.js";
8
9
 
9
10
  export const SAVED_SITE_FILE = "site.json";
11
+ /** The steps every run leaves in its folder, so the next run can repeat them without a model. */
12
+ export const STEPS_FILE = "steps.json";
10
13
 
11
14
  /** The steps a run took, as fixed steps a scripted run can repeat. */
12
15
  export function replayableJourney(run) {
13
- const steps = run.steps.filter((s) => s.index > 0);
16
+ const steps = (run.steps ?? []).filter((s) => s.index > 0);
14
17
  const start = steps[0]?.replay?.action === "goto" ? steps[0].replay.url : undefined;
15
18
  return {
16
19
  id: run.journeyId,
@@ -24,6 +27,30 @@ export function replayableJourney(run) {
24
27
  };
25
28
  }
26
29
 
30
+ /** What a run folder's steps.json holds: one entry per journey and viewport, leaving out journeys that took no repeatable step. */
31
+ export function savedSteps({ site, runs }) {
32
+ return { site, journeys: runs.map(replayableJourney).filter((j) => j.steps.length || j.start) };
33
+ }
34
+
35
+ /**
36
+ * The steps of the site's newest completed run of a journey, read from the steps.json each run leaves
37
+ * behind: { runId, viewport, start, steps } or null. The same viewport is preferred; when no run completed
38
+ * the journey on it, the newest completed run on another viewport is used, since most sites take the
39
+ * same path on both. Runs that did not complete the journey are skipped, since their steps led nowhere.
40
+ */
41
+ export async function lastSteps(dataDir, site, { journeyId, viewport }) {
42
+ let other = null;
43
+ for (const runId of (await listRunIds(dataDir, site)).reverse()) {
44
+ const saved = await readFile(path.join(runsDir(dataDir, site), runId, STEPS_FILE), "utf8").then(JSON.parse).catch(() => null);
45
+ const done = (saved?.journeys ?? []).filter((j) => j.id === journeyId && !j.incomplete && (j.steps?.length || j.start));
46
+ const pick = (j) => ({ runId, viewport: j.viewport, start: j.start, steps: j.steps ?? [] });
47
+ const same = done.find((j) => j.viewport === viewport);
48
+ if (same) return pick(same);
49
+ if (done.length && !other) other = pick(done[0]);
50
+ }
51
+ return other;
52
+ }
53
+
27
54
  /**
28
55
  * Writes the steps each journey took into journeys[].steps of a config file, so a later
29
56
  * --scripted --no-ai run repeats them without an AI model. Creates the file when it doesn't exist.