@analitikisler/ai-tag-debugger 0.0.0-stage → 0.5.2

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.
@@ -0,0 +1,250 @@
1
+ // AI providers. Bring your own API key: Anthropic (Claude), OpenAI, Google Gemini, or any
2
+ // OpenAI-compatible API (Kimi, GLM, DeepSeek, Mistral, Groq, OpenRouter, a local Ollama...).
3
+ //
4
+ // Every provider exposes the same two things:
5
+ // conversation({ system, tools, firstMessage }) -> { next(), addToolResults(results), addUserText(text) }
6
+ // next() returns { toolCalls: [{ id, name, input }], stop: "tool_use" | "end" | "refusal" | "max_tokens" }
7
+ // json({ system, texts, schema }) -> { value } or { error }
8
+ // Tools use one neutral shape: { name, description, schema } where schema is JSON Schema.
9
+
10
+ export const PROVIDERS = {
11
+ anthropic: { label: "Anthropic (Claude)", env: "ANTHROPIC_API_KEY", defaultModel: "claude-opus-5-5" },
12
+ openai: { label: "OpenAI", env: "OPENAI_API_KEY", defaultModel: "gpt-5.5" },
13
+ gemini: { label: "Google Gemini", env: "GEMINI_API_KEY", defaultModel: "gemini-pro-latest" },
14
+ // Any API that speaks the OpenAI chat completions protocol. Needs a base URL and a model.
15
+ "openai-compatible": { label: "OpenAI-compatible API", env: "OPENAI_COMPATIBLE_API_KEY", defaultModel: null },
16
+ };
17
+
18
+ /** Picks the provider: explicit name first, then whichever API key is set. */
19
+ export function resolveProviderName(name, env = process.env) {
20
+ if (name) {
21
+ if (!PROVIDERS[name]) throw new Error(`Unknown provider "${name}". Use: ${Object.keys(PROVIDERS).join(", ")}`);
22
+ return name;
23
+ }
24
+ if (env.OPENAI_COMPATIBLE_BASE_URL) return "openai-compatible";
25
+ if (env.ANTHROPIC_API_KEY) return "anthropic";
26
+ if (env.OPENAI_API_KEY) return "openai";
27
+ if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) return "gemini";
28
+ return "anthropic";
29
+ }
30
+
31
+ /**
32
+ * @param {{ name?: string, model?: string, baseUrl?: string, client?: any }} opts
33
+ * baseUrl: for openai-compatible, e.g. https://api.deepseek.com or http://localhost:11434/v1.
34
+ * client: an already constructed SDK client (used by tests).
35
+ */
36
+ export async function createProvider(opts = {}, env = process.env) {
37
+ // A bare client (tests, embedding) is an Anthropic client unless named otherwise.
38
+ const name = opts.client && !opts.name ? "anthropic" : opts.baseUrl && !opts.name ? "openai-compatible" : resolveProviderName(opts.name, env);
39
+ if (name === "openai-compatible") {
40
+ const baseURL = opts.baseUrl ?? env.OPENAI_COMPATIBLE_BASE_URL;
41
+ const model = opts.model ?? env.OPENAI_COMPATIBLE_MODEL;
42
+ if (!baseURL) throw new Error("openai-compatible needs the API address: --base-url or OPENAI_COMPATIBLE_BASE_URL, e.g. https://api.deepseek.com");
43
+ if (!model) throw new Error("openai-compatible needs a model id: --model or OPENAI_COMPATIBLE_MODEL.");
44
+ // Local servers such as Ollama need no key, but the SDK wants a non-empty one.
45
+ const apiKey = env.OPENAI_COMPATIBLE_API_KEY || "not-needed";
46
+ const client = opts.client ?? new (await import("openai")).default({ baseURL, apiKey });
47
+ return openaiProvider(client, model, { compatible: true, label: new URL(baseURL).host });
48
+ }
49
+ const model = opts.model ?? PROVIDERS[name].defaultModel;
50
+ if (name === "anthropic") return anthropicProvider(opts.client ?? new (await import("@anthropic-ai/sdk")).default(), model);
51
+ if (name === "openai") return openaiProvider(opts.client ?? new (await import("openai")).default(), model);
52
+ const { GoogleGenAI } = await import("@google/genai");
53
+ const apiKey = env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY;
54
+ return geminiProvider(opts.client ?? new GoogleGenAI(apiKey ? { apiKey } : {}), model);
55
+ }
56
+
57
+ // --- Anthropic ---------------------------------------------------------------
58
+
59
+ function anthropicProvider(client, model) {
60
+ const common = { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" };
61
+ return {
62
+ name: "anthropic",
63
+ model,
64
+ conversation({ system, tools, firstMessage }) {
65
+ const messages = [{ role: "user", content: firstMessage }];
66
+ const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.schema }));
67
+ return {
68
+ async next() {
69
+ const response = await client.beta.messages.create({
70
+ model,
71
+ max_tokens: 16000,
72
+ system,
73
+ tools: toolDefs,
74
+ messages,
75
+ // Navigation is simple step-by-step work; low effort keeps runs fast and cheap.
76
+ output_config: { effort: "low" },
77
+ cache_control: { type: "ephemeral" },
78
+ ...common,
79
+ });
80
+ if (response.stop_reason === "refusal") return { toolCalls: [], stop: "refusal" };
81
+ messages.push({ role: "assistant", content: response.content });
82
+ const toolCalls = response.content.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, name: b.name, input: b.input }));
83
+ return { toolCalls, stop: toolCalls.length ? "tool_use" : response.stop_reason === "max_tokens" ? "max_tokens" : "end" };
84
+ },
85
+ addToolResults(results) {
86
+ messages.push({
87
+ role: "user",
88
+ content: results.map((r) => ({ type: "tool_result", tool_use_id: r.id, content: r.content, ...(r.isError ? { is_error: true } : {}) })),
89
+ });
90
+ },
91
+ addUserText(text) {
92
+ messages.push({ role: "user", content: text });
93
+ },
94
+ };
95
+ },
96
+ async json({ system, texts, schema }) {
97
+ const stream = client.beta.messages.stream({
98
+ model,
99
+ max_tokens: 64000,
100
+ system,
101
+ messages: [{ role: "user", content: texts.map((text) => ({ type: "text", text })) }],
102
+ output_config: { effort: "high", format: { type: "json_schema", schema } },
103
+ ...common,
104
+ });
105
+ const message = await stream.finalMessage();
106
+ if (message.stop_reason === "refusal" || message.stop_reason === "max_tokens") return { error: message.stop_reason };
107
+ return { value: JSON.parse(message.content.filter((b) => b.type === "text").map((b) => b.text).join("")) };
108
+ },
109
+ };
110
+ }
111
+
112
+ // --- OpenAI ------------------------------------------------------------------
113
+
114
+ // Also serves OpenAI-compatible APIs. Those often lack reasoning_effort and strict JSON
115
+ // schemas, so for them the first is left out and JSON falls back to plain JSON mode.
116
+ function openaiProvider(client, model, { compatible = false, label } = {}) {
117
+ const effort = (level) => (compatible ? {} : { reasoning_effort: level });
118
+ return {
119
+ name: compatible ? `openai-compatible (${label})` : "openai",
120
+ model,
121
+ conversation({ system, tools, firstMessage }) {
122
+ const messages = [
123
+ { role: "system", content: system },
124
+ { role: "user", content: firstMessage },
125
+ ];
126
+ const toolDefs = tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.schema } }));
127
+ return {
128
+ async next() {
129
+ const response = await client.chat.completions.create({ model, messages, tools: toolDefs, ...effort("low") });
130
+ const choice = response.choices[0];
131
+ const message = choice.message;
132
+ if (message.refusal) return { toolCalls: [], stop: "refusal" };
133
+ messages.push(message);
134
+ const toolCalls = (message.tool_calls ?? [])
135
+ .filter((c) => c.type === "function")
136
+ .map((c) => ({ id: c.id, name: c.function.name, input: safeParse(c.function.arguments) }));
137
+ return { toolCalls, stop: toolCalls.length ? "tool_use" : choice.finish_reason === "length" ? "max_tokens" : "end" };
138
+ },
139
+ addToolResults(results) {
140
+ for (const r of results) messages.push({ role: "tool", tool_call_id: r.id, content: r.isError ? `Error: ${r.content}` : r.content });
141
+ },
142
+ addUserText(text) {
143
+ messages.push({ role: "user", content: text });
144
+ },
145
+ };
146
+ },
147
+ async json({ system, texts, schema }) {
148
+ const messages = [
149
+ { role: "system", content: system },
150
+ { role: "user", content: texts.join("\n\n") },
151
+ ];
152
+ let response;
153
+ try {
154
+ response = await client.chat.completions.create({
155
+ model,
156
+ ...effort("high"),
157
+ messages,
158
+ response_format: { type: "json_schema", json_schema: { name: "result", schema, strict: true } },
159
+ });
160
+ } catch (err) {
161
+ if (!compatible) throw err;
162
+ // No structured outputs here: ask for plain JSON and describe the shape instead.
163
+ response = await client.chat.completions.create({
164
+ model,
165
+ messages: [{ role: "system", content: `${system}\n\nReply with only a JSON object that matches this JSON Schema:\n${JSON.stringify(schema)}` }, messages[1]],
166
+ response_format: { type: "json_object" },
167
+ });
168
+ }
169
+ const message = response.choices[0].message;
170
+ if (message.refusal) return { error: "refusal" };
171
+ if (response.choices[0].finish_reason === "length") return { error: "max_tokens" };
172
+ return { value: parseJsonReply(message.content) };
173
+ },
174
+ };
175
+ }
176
+
177
+ // --- Google Gemini -----------------------------------------------------------
178
+
179
+ function geminiProvider(ai, model) {
180
+ return {
181
+ name: "gemini",
182
+ model,
183
+ conversation({ system, tools, firstMessage }) {
184
+ const contents = [{ role: "user", parts: [{ text: firstMessage }] }];
185
+ const config = {
186
+ systemInstruction: system,
187
+ tools: [{ functionDeclarations: tools.map((t) => ({ name: t.name, description: t.description, parametersJsonSchema: t.schema })) }],
188
+ };
189
+ let pendingNames = new Map();
190
+ return {
191
+ async next() {
192
+ const response = await ai.models.generateContent({ model, contents, config });
193
+ const candidate = response.candidates?.[0];
194
+ if (!candidate?.content) return { toolCalls: [], stop: candidate?.finishReason === "MAX_TOKENS" ? "max_tokens" : "refusal" };
195
+ contents.push(candidate.content);
196
+ const calls = (candidate.content.parts ?? []).filter((p) => p.functionCall).map((p) => p.functionCall);
197
+ pendingNames = new Map();
198
+ const toolCalls = calls.map((c, i) => {
199
+ const id = c.id ?? `call_${contents.length}_${i}`;
200
+ pendingNames.set(id, { name: c.name, id: c.id });
201
+ return { id, name: c.name, input: c.args ?? {} };
202
+ });
203
+ return { toolCalls, stop: toolCalls.length ? "tool_use" : candidate.finishReason === "MAX_TOKENS" ? "max_tokens" : "end" };
204
+ },
205
+ addToolResults(results) {
206
+ contents.push({
207
+ role: "user",
208
+ parts: results.map((r) => {
209
+ const call = pendingNames.get(r.id) ?? { name: "unknown" };
210
+ return {
211
+ functionResponse: {
212
+ ...(call.id ? { id: call.id } : {}),
213
+ name: call.name,
214
+ response: r.isError ? { error: r.content } : { output: r.content },
215
+ },
216
+ };
217
+ }),
218
+ });
219
+ },
220
+ addUserText(text) {
221
+ contents.push({ role: "user", parts: [{ text }] });
222
+ },
223
+ };
224
+ },
225
+ async json({ system, texts, schema }) {
226
+ const response = await ai.models.generateContent({
227
+ model,
228
+ contents: [{ role: "user", parts: texts.map((text) => ({ text })) }],
229
+ config: { systemInstruction: system, responseMimeType: "application/json", responseJsonSchema: schema },
230
+ });
231
+ if (!response.text) return { error: response.candidates?.[0]?.finishReason ?? "empty response" };
232
+ return { value: JSON.parse(response.text) };
233
+ },
234
+ };
235
+ }
236
+
237
+ /** Parses a JSON reply, also when a model wraps it in a Markdown code fence. */
238
+ function parseJsonReply(text) {
239
+ const trimmed = String(text ?? "").trim();
240
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
241
+ return JSON.parse(fenced ? fenced[1] : trimmed);
242
+ }
243
+
244
+ function safeParse(text) {
245
+ try {
246
+ return JSON.parse(text || "{}");
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
package/src/redact.js ADDED
@@ -0,0 +1,130 @@
1
+ // Ad platforms receive user data for matching (GA4 and Google Ads enhanced conversions,
2
+ // Meta advanced matching, TikTok context.user), and sites push it to the dataLayer for
3
+ // their tags. The audit stores and shows requests and pushes, so those values are replaced
4
+ // with a marker that keeps only their length and whether they look hashed. The plain ones
5
+ // are reported as a personal data risk.
6
+
7
+ const USER_KEYS = new Set([
8
+ "em", "ph", "fn", "ln", "ge", "db", "ct", "st", "zp", "country", "external_id", "uid", "user_id", "_uid",
9
+ "email", "phone", "phone_number", "first_name", "last_name", "street", "city", "region", "postal_code", "zip",
10
+ "sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone", "email_address",
11
+ ]);
12
+ // In dataLayer pushes, short names like "city" or "country" are often page data, so only
13
+ // these names, values under a user data object, and email-like values are redacted there.
14
+ const PUSH_KEYS = new Set(["email", "email_address", "phone", "phone_number", "sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone"]);
15
+ const USER_OBJECTS = /(^|\.)(user_data|user_provided_data|enhanced_conversion_data)(\.|$)/;
16
+
17
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18
+ /** A value that is an email address, whatever its field is called. */
19
+ const isEmail = (v) => typeof v === "string" && EMAIL.test(v.trim());
20
+
21
+ /** Whether a request parameter (or dotted JSON path) carries user data for ad matching. */
22
+ export function isUserDataKey(key) {
23
+ const k = String(key).toLowerCase();
24
+ if (/^ud(ff)?\[.+\]$/.test(k)) return true; // Meta advanced matching: ud[em], and udff[em] in newer pixels
25
+ if (/^(ep|epn|up|upn)\.(user_data|email|phone|user_id)/.test(k)) return true;
26
+ if (/^context\.user\.(email|phone|phone_number|external_id)$/.test(k)) return true;
27
+ if (USER_OBJECTS.test(k)) return true;
28
+ return USER_KEYS.has(k.split(".").pop());
29
+ }
30
+
31
+ /** Whether a dataLayer push field carries user data: see PUSH_KEYS. */
32
+ const isPushUserKey = (path) => USER_OBJECTS.test(path.toLowerCase()) || PUSH_KEYS.has(path.toLowerCase().split(".").pop());
33
+
34
+ /** "tv.1~em.<hash>" and plain hex or base64 digests count as hashed; anything with @ or spaces doesn't. */
35
+ export function looksHashed(value) {
36
+ const v = String(value);
37
+ if (/[@\s]/.test(v)) return false;
38
+ return v.split("~").every((p) => p === "" || /^tv\.\d+$/.test(p) || /^[A-Za-z0-9+/=_-]{32,}$/.test(p.replace(/^[a-z0-9]{1,4}\./i, "")));
39
+ }
40
+
41
+ const marker = (value) => `[redacted: ${String(value).length} chars, ${looksHashed(value) ? "looks hashed" : "plain"}]`;
42
+ const emptyFound = () => ({ fields: [], plain: [] });
43
+
44
+ /** Collects what was redacted: { fields: keys, plain: keys whose value was not hashed }. */
45
+ function note(found, key, value) {
46
+ found.fields.push(key);
47
+ if (!looksHashed(value)) found.plain.push(key);
48
+ return marker(value);
49
+ }
50
+
51
+ /** Redacts form-encoded parameters (a URL query or a GA4 body line). Returns null when nothing needed it. */
52
+ function redactParamString(text, found) {
53
+ const params = new URLSearchParams(text);
54
+ if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v))) return null;
55
+ const out = new URLSearchParams();
56
+ for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v) ? note(found, k, v) : v);
57
+ return out.toString();
58
+ }
59
+
60
+ /** Redacts user data in a URL's query. Returns the URL unchanged when there is none. */
61
+ export function redactUrl(url, found = emptyFound()) {
62
+ let u;
63
+ try {
64
+ u = new URL(url);
65
+ } catch {
66
+ return { url, ...found };
67
+ }
68
+ const query = redactParamString(u.search, found);
69
+ if (query === null) return { url, ...found };
70
+ u.search = query;
71
+ return { url: u.href, ...found };
72
+ }
73
+
74
+ function redactJson(value, path, found, isKey) {
75
+ if (Array.isArray(value)) return value.map((v) => redactJson(v, path, found, isKey));
76
+ if (value && typeof value === "object") {
77
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactJson(v, path ? `${path}.${k}` : k, found, isKey)]));
78
+ }
79
+ if ((typeof value === "string" || typeof value === "number") && ((path && isKey(path)) || isEmail(value))) return note(found, path || "(value)", value);
80
+ return value;
81
+ }
82
+
83
+ // "key": "value" and "key": 123 pairs, for JSON cut at the size limit.
84
+ const PAIR = /"([^"\\]{1,64})"(\s*:\s*)("(?:[^"\\]|\\.)*"|-?\d[\d.eE+-]*)/g;
85
+ // A string value the cut left open at the very end.
86
+ const OPEN_VALUE = /("(?:[^"\\]{1,64})"\s*:\s*)"(?:[^"\\]|\\.)*$/;
87
+
88
+ /** Redacts user data in a POST body: JSON (TikTok) or form-encoded lines (GA4 batches). */
89
+ export function redactBody(body, found = emptyFound()) {
90
+ const text = String(body ?? "");
91
+ const trimmed = text.trim();
92
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
93
+ try {
94
+ return { text: JSON.stringify(redactJson(JSON.parse(trimmed), "", found, isUserDataKey)), ...found };
95
+ } catch {
96
+ // Cut at the size limit: redact pairs by name or email-like value, and drop a value left open at the end.
97
+ const redacted = text
98
+ .replace(PAIR, (all, k, sep, raw) => {
99
+ const v = raw.startsWith('"') ? raw.slice(1, -1) : raw;
100
+ return isUserDataKey(k) || isEmail(v) ? `"${k}"${sep}"${note(found, k, v)}"` : all;
101
+ })
102
+ .replace(OPEN_VALUE, '$1"[cut]');
103
+ return { text: redacted, ...found };
104
+ }
105
+ }
106
+ if (/^[^\s{]*=/.test(trimmed)) {
107
+ const lines = text.split("\n").map((line) => redactParamString(line, found) ?? line);
108
+ return { text: lines.join("\n"), ...found };
109
+ }
110
+ return { text, ...found };
111
+ }
112
+
113
+ /** Redacts user-data entries in parsed hit parameters. */
114
+ export function redactParams(params, found = emptyFound()) {
115
+ const out = {};
116
+ for (const [k, v] of Object.entries(params ?? {})) out[k] = isUserDataKey(k) || isEmail(v) ? note(found, k, v) : v;
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Redacts user data in a dataLayer push (GTM objects or gtag.js arguments):
122
+ * email and phone fields, anything under user_data, and email-like values.
123
+ */
124
+ export function redactPush(value, found = emptyFound()) {
125
+ // gtag("set", "user_data", {...}) has no user_data key: the objects after the name are the user data.
126
+ if (Array.isArray(value) && value.includes("user_data")) {
127
+ return { value: value.map((v) => (v && typeof v === "object" ? redactJson(v, "user_data", found, isPushUserKey) : v)), ...found };
128
+ }
129
+ return { value: redactJson(value, "", found, isPushUserKey), ...found };
130
+ }