@ainyc/canonry 1.20.1 → 1.21.1

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.
@@ -35,7 +35,7 @@ function loadConfig() {
35
35
  throw new Error(
36
36
  `Config not found at ${configPath}.
37
37
  Run "canonry init" to set up interactively, or "canonry init --gemini-key <key>" for non-interactive setup.
38
- For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET).`
38
+ For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, PERPLEXITY_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET).`
39
39
  );
40
40
  }
41
41
  const raw = fs.readFileSync(configPath, "utf-8");
@@ -72,6 +72,16 @@ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "ca
72
72
  } catch {
73
73
  }
74
74
  }
75
+ if (parsed.basePath) {
76
+ const normalizedBase = "/" + parsed.basePath.replace(/^\/|\/$/g, "");
77
+ try {
78
+ const url = new URL(parsed.apiUrl);
79
+ if (normalizedBase !== "/" && !url.pathname.startsWith(normalizedBase)) {
80
+ parsed.apiUrl = url.origin + normalizedBase;
81
+ }
82
+ } catch {
83
+ }
84
+ }
75
85
  return parsed;
76
86
  }
77
87
  function saveConfig(config) {
@@ -167,7 +177,7 @@ import { fileURLToPath } from "url";
167
177
  import Fastify from "fastify";
168
178
 
169
179
  // ../contracts/src/config-schema.ts
170
- import { z as z3 } from "zod";
180
+ import { z as z4 } from "zod";
171
181
 
172
182
  // ../contracts/src/provider.ts
173
183
  import { z } from "zod";
@@ -176,30 +186,18 @@ var providerQuotaPolicySchema = z.object({
176
186
  maxRequestsPerMinute: z.number().int().positive(),
177
187
  maxRequestsPerDay: z.number().int().positive()
178
188
  });
179
- var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
180
- var providerNameSchema = z.enum(PROVIDER_NAMES);
181
- var PROVIDER_MODE = {
182
- gemini: "api",
183
- openai: "api",
184
- claude: "api",
185
- local: "api",
186
- "cdp:chatgpt": "browser"
187
- };
189
+ var providerNameSchema = z.string().min(1);
190
+ var apiProviderNameSchema = z.string().min(1);
188
191
  function isBrowserProvider(name) {
189
- return PROVIDER_MODE[name] === "browser";
192
+ return name.startsWith("cdp:");
190
193
  }
191
194
  var CDP_TARGETS = ["cdp:chatgpt"];
192
- function parseProviderName(input) {
193
- const lower = input.trim().toLowerCase();
194
- return PROVIDER_NAMES.includes(lower) ? lower : void 0;
195
- }
196
195
  function resolveProviderInput(input) {
197
196
  const lower = input.trim().toLowerCase();
198
197
  if (lower === "cdp") {
199
198
  return [...CDP_TARGETS];
200
199
  }
201
- const parsed = parseProviderName(lower);
202
- return parsed ? [parsed] : [];
200
+ return lower ? [lower] : [];
203
201
  }
204
202
  var locationContextSchema = z.object({
205
203
  label: z.string().min(1),
@@ -229,118 +227,148 @@ var notificationDtoSchema = z2.object({
229
227
  updatedAt: z2.string()
230
228
  });
231
229
 
230
+ // ../contracts/src/project.ts
231
+ import { z as z3 } from "zod";
232
+ var configSourceSchema = z3.enum(["cli", "api", "config-file"]);
233
+ function findDuplicateLocationLabels(locations) {
234
+ const seen = /* @__PURE__ */ new Set();
235
+ const duplicates = /* @__PURE__ */ new Set();
236
+ for (const location of locations) {
237
+ if (seen.has(location.label)) {
238
+ duplicates.add(location.label);
239
+ continue;
240
+ }
241
+ seen.add(location.label);
242
+ }
243
+ return [...duplicates];
244
+ }
245
+ function hasLocationLabel(locations, label) {
246
+ if (!label) return true;
247
+ return locations.some((location) => location.label === label);
248
+ }
249
+ var projectUpsertRequestSchema = z3.object({
250
+ displayName: z3.string().min(1),
251
+ canonicalDomain: z3.string().min(1),
252
+ ownedDomains: z3.array(z3.string().min(1)).optional(),
253
+ country: z3.string().length(2),
254
+ language: z3.string().min(2),
255
+ tags: z3.array(z3.string()).optional(),
256
+ labels: z3.record(z3.string(), z3.string()).optional(),
257
+ providers: z3.array(providerNameSchema).optional(),
258
+ locations: z3.array(locationContextSchema).optional(),
259
+ defaultLocation: z3.string().nullable().optional(),
260
+ configSource: configSourceSchema.optional()
261
+ });
262
+ var projectDtoSchema = z3.object({
263
+ id: z3.string(),
264
+ name: z3.string(),
265
+ displayName: z3.string().optional(),
266
+ canonicalDomain: z3.string(),
267
+ ownedDomains: z3.array(z3.string()).default([]),
268
+ country: z3.string().length(2),
269
+ language: z3.string().min(2),
270
+ tags: z3.array(z3.string()).default([]),
271
+ labels: z3.record(z3.string(), z3.string()).default({}),
272
+ locations: z3.array(locationContextSchema).default([]),
273
+ defaultLocation: z3.string().nullable().optional(),
274
+ configSource: configSourceSchema.default("cli"),
275
+ configRevision: z3.number().int().positive().default(1),
276
+ createdAt: z3.string().optional(),
277
+ updatedAt: z3.string().optional()
278
+ });
279
+ function normalizeProjectDomain(input) {
280
+ let domain = input.trim().toLowerCase();
281
+ try {
282
+ if (domain.includes("://")) {
283
+ domain = new URL(domain).hostname.toLowerCase();
284
+ }
285
+ } catch {
286
+ }
287
+ return domain.replace(/^www\./, "");
288
+ }
289
+ function effectiveDomains(project) {
290
+ const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
291
+ const seen = /* @__PURE__ */ new Set();
292
+ const result = [];
293
+ for (const d of all) {
294
+ const trimmed = d.trim();
295
+ if (!trimmed) continue;
296
+ const norm = normalizeProjectDomain(trimmed);
297
+ if (seen.has(norm)) continue;
298
+ seen.add(norm);
299
+ result.push(trimmed);
300
+ }
301
+ return result;
302
+ }
303
+
232
304
  // ../contracts/src/config-schema.ts
233
- var configMetadataSchema = z3.object({
234
- name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
305
+ var configMetadataSchema = z4.object({
306
+ name: z4.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
235
307
  message: "Name must be a lowercase slug (letters, numbers, hyphens)"
236
308
  }),
237
- labels: z3.record(z3.string(), z3.string()).optional().default({})
309
+ labels: z4.record(z4.string(), z4.string()).optional().default({})
238
310
  });
239
- var configScheduleSchema = z3.object({
240
- preset: z3.string().optional(),
241
- cron: z3.string().optional(),
242
- timezone: z3.string().optional().default("UTC"),
243
- providers: z3.array(providerNameSchema).optional().default([])
311
+ var configScheduleSchema = z4.object({
312
+ preset: z4.string().optional(),
313
+ cron: z4.string().optional(),
314
+ timezone: z4.string().optional().default("UTC"),
315
+ providers: z4.array(providerNameSchema).optional().default([])
244
316
  }).refine(
245
317
  (data) => data.preset && !data.cron || !data.preset && data.cron,
246
318
  { message: 'Exactly one of "preset" or "cron" must be provided' }
247
319
  ).optional();
248
- var configNotificationSchema = z3.object({
249
- channel: z3.literal("webhook"),
250
- url: z3.string().url(),
251
- events: z3.array(notificationEventSchema).min(1)
320
+ var configNotificationSchema = z4.object({
321
+ channel: z4.literal("webhook"),
322
+ url: z4.string().url(),
323
+ events: z4.array(notificationEventSchema).min(1)
252
324
  });
253
- var configGoogleSchema = z3.object({
254
- gsc: z3.object({
255
- propertyUrl: z3.string()
325
+ var configGoogleSchema = z4.object({
326
+ gsc: z4.object({
327
+ propertyUrl: z4.string()
256
328
  }).optional(),
257
- syncSchedule: z3.object({
258
- preset: z3.string().optional(),
259
- cron: z3.string().optional()
329
+ syncSchedule: z4.object({
330
+ preset: z4.string().optional(),
331
+ cron: z4.string().optional()
260
332
  }).optional()
261
333
  }).optional();
262
- var configSpecSchema = z3.object({
263
- displayName: z3.string().min(1),
264
- canonicalDomain: z3.string().min(1),
265
- ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
266
- country: z3.string().length(2),
267
- language: z3.string().min(2),
268
- keywords: z3.array(z3.string().min(1)).optional().default([]),
269
- competitors: z3.array(z3.string().min(1)).optional().default([]),
270
- providers: z3.array(providerNameSchema).optional().default([]),
271
- locations: z3.array(locationContextSchema).optional().default([]),
272
- defaultLocation: z3.string().optional(),
334
+ var configSpecSchema = z4.object({
335
+ displayName: z4.string().min(1),
336
+ canonicalDomain: z4.string().min(1),
337
+ ownedDomains: z4.array(z4.string().min(1)).optional().default([]),
338
+ country: z4.string().length(2),
339
+ language: z4.string().min(2),
340
+ keywords: z4.array(z4.string().min(1)).optional().default([]),
341
+ competitors: z4.array(z4.string().min(1)).optional().default([]),
342
+ providers: z4.array(providerNameSchema).optional().default([]),
343
+ locations: z4.array(locationContextSchema).optional().default([]),
344
+ defaultLocation: z4.string().optional(),
273
345
  schedule: configScheduleSchema,
274
- notifications: z3.array(configNotificationSchema).optional().default([]),
346
+ notifications: z4.array(configNotificationSchema).optional().default([]),
275
347
  google: configGoogleSchema
348
+ }).superRefine((spec, ctx) => {
349
+ const duplicateLabels = findDuplicateLocationLabels(spec.locations);
350
+ if (duplicateLabels.length > 0) {
351
+ ctx.addIssue({
352
+ code: "custom",
353
+ message: `Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`,
354
+ path: ["locations"]
355
+ });
356
+ }
357
+ if (!hasLocationLabel(spec.locations, spec.defaultLocation)) {
358
+ ctx.addIssue({
359
+ code: "custom",
360
+ message: `defaultLocation "${spec.defaultLocation}" must match a configured location label`,
361
+ path: ["defaultLocation"]
362
+ });
363
+ }
276
364
  });
277
- var projectConfigSchema = z3.object({
278
- apiVersion: z3.literal("canonry/v1"),
279
- kind: z3.literal("Project"),
365
+ var projectConfigSchema = z4.object({
366
+ apiVersion: z4.literal("canonry/v1"),
367
+ kind: z4.literal("Project"),
280
368
  metadata: configMetadataSchema,
281
369
  spec: configSpecSchema
282
370
  });
283
371
 
284
- // ../contracts/src/models.ts
285
- var MODEL_REGISTRY = {
286
- gemini: {
287
- defaultModel: "gemini-3-flash",
288
- validationPattern: /^gemini-/,
289
- validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
290
- knownModels: [
291
- { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
292
- { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
293
- { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
294
- { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
295
- ]
296
- },
297
- openai: {
298
- defaultModel: "gpt-5.4",
299
- validationPattern: /^(gpt-|o\d)/,
300
- validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
301
- knownModels: [
302
- { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
303
- { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
304
- { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
305
- { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
306
- { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
307
- { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
308
- ]
309
- },
310
- claude: {
311
- defaultModel: "claude-sonnet-4-6",
312
- validationPattern: /^claude-/,
313
- validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
314
- knownModels: [
315
- { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
316
- { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
317
- { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
318
- ]
319
- },
320
- local: {
321
- defaultModel: "llama3",
322
- validationPattern: /./,
323
- validationHint: "any model name accepted",
324
- knownModels: [
325
- { id: "llama3", displayName: "Llama 3", tier: "standard" }
326
- ]
327
- },
328
- "cdp:chatgpt": {
329
- defaultModel: "chatgpt-web",
330
- validationPattern: /./,
331
- validationHint: "model is detected from the ChatGPT web UI",
332
- knownModels: [
333
- { id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
334
- ]
335
- }
336
- };
337
- function getDefaultModel(provider) {
338
- return MODEL_REGISTRY[provider].defaultModel;
339
- }
340
- function isValidModelName(provider, model) {
341
- return MODEL_REGISTRY[provider].validationPattern.test(model);
342
- }
343
-
344
372
  // ../contracts/src/errors.ts
345
373
  var AppError = class extends Error {
346
374
  code;
@@ -384,177 +412,135 @@ function runNotCancellable(runId, status) {
384
412
  function unsupportedKind(kind) {
385
413
  return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
386
414
  }
415
+ function notImplemented(message) {
416
+ return new AppError("NOT_IMPLEMENTED", message, 501);
417
+ }
387
418
 
388
419
  // ../contracts/src/google.ts
389
- import { z as z4 } from "zod";
390
- var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
391
- var googleConnectionDtoSchema = z4.object({
392
- id: z4.string(),
393
- domain: z4.string(),
394
- connectionType: googleConnectionTypeSchema,
395
- propertyId: z4.string().nullable().optional(),
396
- sitemapUrl: z4.string().nullable().optional(),
397
- scopes: z4.array(z4.string()).default([]),
398
- createdAt: z4.string(),
399
- updatedAt: z4.string()
400
- });
401
- var gscSearchDataDtoSchema = z4.object({
402
- date: z4.string(),
403
- query: z4.string(),
404
- page: z4.string(),
405
- country: z4.string().nullable().optional(),
406
- device: z4.string().nullable().optional(),
407
- clicks: z4.number(),
408
- impressions: z4.number(),
409
- ctr: z4.number(),
410
- position: z4.number()
411
- });
412
- var gscUrlInspectionDtoSchema = z4.object({
413
- id: z4.string(),
414
- url: z4.string(),
415
- indexingState: z4.string().nullable().optional(),
416
- verdict: z4.string().nullable().optional(),
417
- coverageState: z4.string().nullable().optional(),
418
- pageFetchState: z4.string().nullable().optional(),
419
- robotsTxtState: z4.string().nullable().optional(),
420
- crawlTime: z4.string().nullable().optional(),
421
- lastCrawlResult: z4.string().nullable().optional(),
422
- isMobileFriendly: z4.boolean().nullable().optional(),
423
- richResults: z4.array(z4.string()).default([]),
424
- inspectedAt: z4.string()
425
- });
426
- var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
427
- var gscDeindexedRowSchema = z4.object({
428
- url: z4.string(),
429
- previousState: z4.string().nullable(),
430
- currentState: z4.string().nullable(),
431
- transitionDate: z4.string()
432
- });
433
- var gscReasonGroupSchema = z4.object({
434
- reason: z4.string(),
435
- count: z4.number(),
436
- urls: z4.array(gscUrlInspectionDtoSchema).default([])
437
- });
438
- var gscCoverageSummaryDtoSchema = z4.object({
439
- summary: z4.object({
440
- total: z4.number(),
441
- indexed: z4.number(),
442
- notIndexed: z4.number(),
443
- deindexed: z4.number(),
444
- percentage: z4.number()
445
- }),
446
- lastInspectedAt: z4.string().nullable(),
447
- indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
448
- notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
449
- deindexed: z4.array(gscDeindexedRowSchema).default([]),
450
- reasonGroups: z4.array(gscReasonGroupSchema).default([])
451
- });
452
- var indexingNotificationDtoSchema = z4.object({
453
- url: z4.string(),
454
- type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
455
- notifiedAt: z4.string()
456
- });
457
- var indexingRequestResultDtoSchema = z4.object({
458
- url: z4.string(),
459
- type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
460
- notifiedAt: z4.string(),
461
- status: z4.enum(["success", "error"]),
462
- error: z4.string().optional()
463
- });
464
- var gscCoverageSnapshotDtoSchema = z4.object({
465
- date: z4.string(),
466
- indexed: z4.number(),
467
- notIndexed: z4.number(),
468
- reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
469
- });
470
-
471
- // ../contracts/src/bing.ts
472
420
  import { z as z5 } from "zod";
473
- var bingConnectionDtoSchema = z5.object({
421
+ var googleConnectionTypeSchema = z5.enum(["gsc", "ga4"]);
422
+ var googleConnectionDtoSchema = z5.object({
474
423
  id: z5.string(),
475
424
  domain: z5.string(),
476
- siteUrl: z5.string().nullable().optional(),
425
+ connectionType: googleConnectionTypeSchema,
426
+ propertyId: z5.string().nullable().optional(),
427
+ sitemapUrl: z5.string().nullable().optional(),
428
+ scopes: z5.array(z5.string()).default([]),
477
429
  createdAt: z5.string(),
478
430
  updatedAt: z5.string()
479
431
  });
480
- var bingUrlInspectionDtoSchema = z5.object({
432
+ var gscSearchDataDtoSchema = z5.object({
433
+ date: z5.string(),
434
+ query: z5.string(),
435
+ page: z5.string(),
436
+ country: z5.string().nullable().optional(),
437
+ device: z5.string().nullable().optional(),
438
+ clicks: z5.number(),
439
+ impressions: z5.number(),
440
+ ctr: z5.number(),
441
+ position: z5.number()
442
+ });
443
+ var gscUrlInspectionDtoSchema = z5.object({
481
444
  id: z5.string(),
482
445
  url: z5.string(),
483
- httpCode: z5.number().nullable().optional(),
484
- inIndex: z5.boolean().nullable().optional(),
485
- lastCrawledDate: z5.string().nullable().optional(),
486
- inIndexDate: z5.string().nullable().optional(),
446
+ indexingState: z5.string().nullable().optional(),
447
+ verdict: z5.string().nullable().optional(),
448
+ coverageState: z5.string().nullable().optional(),
449
+ pageFetchState: z5.string().nullable().optional(),
450
+ robotsTxtState: z5.string().nullable().optional(),
451
+ crawlTime: z5.string().nullable().optional(),
452
+ lastCrawlResult: z5.string().nullable().optional(),
453
+ isMobileFriendly: z5.boolean().nullable().optional(),
454
+ richResults: z5.array(z5.string()).default([]),
487
455
  inspectedAt: z5.string()
488
456
  });
489
- var bingCoverageSummaryDtoSchema = z5.object({
457
+ var indexTransitionSchema = z5.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
458
+ var gscDeindexedRowSchema = z5.object({
459
+ url: z5.string(),
460
+ previousState: z5.string().nullable(),
461
+ currentState: z5.string().nullable(),
462
+ transitionDate: z5.string()
463
+ });
464
+ var gscReasonGroupSchema = z5.object({
465
+ reason: z5.string(),
466
+ count: z5.number(),
467
+ urls: z5.array(gscUrlInspectionDtoSchema).default([])
468
+ });
469
+ var gscCoverageSummaryDtoSchema = z5.object({
490
470
  summary: z5.object({
491
471
  total: z5.number(),
492
472
  indexed: z5.number(),
493
473
  notIndexed: z5.number(),
474
+ deindexed: z5.number(),
494
475
  percentage: z5.number()
495
476
  }),
496
477
  lastInspectedAt: z5.string().nullable(),
497
- indexed: z5.array(bingUrlInspectionDtoSchema).default([]),
498
- notIndexed: z5.array(bingUrlInspectionDtoSchema).default([])
478
+ indexed: z5.array(gscUrlInspectionDtoSchema).default([]),
479
+ notIndexed: z5.array(gscUrlInspectionDtoSchema).default([]),
480
+ deindexed: z5.array(gscDeindexedRowSchema).default([]),
481
+ reasonGroups: z5.array(gscReasonGroupSchema).default([])
499
482
  });
500
- var bingKeywordStatsDtoSchema = z5.object({
501
- query: z5.string(),
502
- impressions: z5.number(),
503
- clicks: z5.number(),
504
- ctr: z5.number(),
505
- averagePosition: z5.number()
483
+ var indexingNotificationDtoSchema = z5.object({
484
+ url: z5.string(),
485
+ type: z5.enum(["URL_UPDATED", "URL_DELETED"]),
486
+ notifiedAt: z5.string()
506
487
  });
507
- var bingSubmitResultDtoSchema = z5.object({
488
+ var indexingRequestResultDtoSchema = z5.object({
508
489
  url: z5.string(),
490
+ type: z5.enum(["URL_UPDATED", "URL_DELETED"]),
491
+ notifiedAt: z5.string(),
509
492
  status: z5.enum(["success", "error"]),
510
- submittedAt: z5.string(),
511
493
  error: z5.string().optional()
512
494
  });
495
+ var gscCoverageSnapshotDtoSchema = z5.object({
496
+ date: z5.string(),
497
+ indexed: z5.number(),
498
+ notIndexed: z5.number(),
499
+ reasonBreakdown: z5.record(z5.string(), z5.number()).default({})
500
+ });
513
501
 
514
- // ../contracts/src/project.ts
502
+ // ../contracts/src/bing.ts
515
503
  import { z as z6 } from "zod";
516
- var configSourceSchema = z6.enum(["cli", "api", "config-file"]);
517
- var projectDtoSchema = z6.object({
504
+ var bingConnectionDtoSchema = z6.object({
518
505
  id: z6.string(),
519
- name: z6.string(),
520
- displayName: z6.string().optional(),
521
- canonicalDomain: z6.string(),
522
- ownedDomains: z6.array(z6.string()).default([]),
523
- country: z6.string().length(2),
524
- language: z6.string().min(2),
525
- tags: z6.array(z6.string()).default([]),
526
- labels: z6.record(z6.string(), z6.string()).default({}),
527
- locations: z6.array(locationContextSchema).default([]),
528
- defaultLocation: z6.string().nullable().optional(),
529
- configSource: configSourceSchema.default("cli"),
530
- configRevision: z6.number().int().positive().default(1),
531
- createdAt: z6.string().optional(),
532
- updatedAt: z6.string().optional()
506
+ domain: z6.string(),
507
+ siteUrl: z6.string().nullable().optional(),
508
+ createdAt: z6.string(),
509
+ updatedAt: z6.string()
510
+ });
511
+ var bingUrlInspectionDtoSchema = z6.object({
512
+ id: z6.string(),
513
+ url: z6.string(),
514
+ httpCode: z6.number().nullable().optional(),
515
+ inIndex: z6.boolean().nullable().optional(),
516
+ lastCrawledDate: z6.string().nullable().optional(),
517
+ inIndexDate: z6.string().nullable().optional(),
518
+ inspectedAt: z6.string()
519
+ });
520
+ var bingCoverageSummaryDtoSchema = z6.object({
521
+ summary: z6.object({
522
+ total: z6.number(),
523
+ indexed: z6.number(),
524
+ notIndexed: z6.number(),
525
+ percentage: z6.number()
526
+ }),
527
+ lastInspectedAt: z6.string().nullable(),
528
+ indexed: z6.array(bingUrlInspectionDtoSchema).default([]),
529
+ notIndexed: z6.array(bingUrlInspectionDtoSchema).default([])
530
+ });
531
+ var bingKeywordStatsDtoSchema = z6.object({
532
+ query: z6.string(),
533
+ impressions: z6.number(),
534
+ clicks: z6.number(),
535
+ ctr: z6.number(),
536
+ averagePosition: z6.number()
537
+ });
538
+ var bingSubmitResultDtoSchema = z6.object({
539
+ url: z6.string(),
540
+ status: z6.enum(["success", "error"]),
541
+ submittedAt: z6.string(),
542
+ error: z6.string().optional()
533
543
  });
534
- function normalizeProjectDomain(input) {
535
- let domain = input.trim().toLowerCase();
536
- try {
537
- if (domain.includes("://")) {
538
- domain = new URL(domain).hostname.toLowerCase();
539
- }
540
- } catch {
541
- }
542
- return domain.replace(/^www\./, "");
543
- }
544
- function effectiveDomains(project) {
545
- const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
546
- const seen = /* @__PURE__ */ new Set();
547
- const result = [];
548
- for (const d of all) {
549
- const trimmed = d.trim();
550
- if (!trimmed) continue;
551
- const norm = normalizeProjectDomain(trimmed);
552
- if (seen.has(norm)) continue;
553
- seen.add(norm);
554
- result.push(trimmed);
555
- }
556
- return result;
557
- }
558
544
 
559
545
  // ../contracts/src/run.ts
560
546
  import { z as z7 } from "zod";
@@ -622,6 +608,16 @@ var scheduleDtoSchema = z8.object({
622
608
  createdAt: z8.string(),
623
609
  updatedAt: z8.string()
624
610
  });
611
+ var scheduleUpsertRequestSchema = z8.object({
612
+ preset: z8.string().optional(),
613
+ cron: z8.string().optional(),
614
+ timezone: z8.string().optional().default("UTC"),
615
+ enabled: z8.boolean().optional().default(true),
616
+ providers: z8.array(providerNameSchema).optional().default([])
617
+ }).refine(
618
+ (data) => data.preset && !data.cron || !data.preset && data.cron,
619
+ { message: 'Exactly one of "preset" or "cron" must be provided' }
620
+ );
625
621
 
626
622
  // ../contracts/src/source-categories.ts
627
623
  var SOURCE_CATEGORY_RULES = [
@@ -1214,7 +1210,44 @@ var MIGRATIONS = [
1214
1210
  // v10: Add sitemapUrl to google_connections for persistent sitemap storage
1215
1211
  `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
1216
1212
  // v11: CDP browser provider — screenshot path for captured evidence
1217
- `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
1213
+ `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`,
1214
+ // v12: Bing Webmaster Tools — bing_connections table
1215
+ `CREATE TABLE IF NOT EXISTS bing_connections (
1216
+ id TEXT PRIMARY KEY,
1217
+ domain TEXT NOT NULL,
1218
+ site_url TEXT,
1219
+ created_at TEXT NOT NULL,
1220
+ updated_at TEXT NOT NULL
1221
+ )`,
1222
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bing_conn_domain ON bing_connections(domain)`,
1223
+ // v12: Bing Webmaster Tools — bing_url_inspections table
1224
+ `CREATE TABLE IF NOT EXISTS bing_url_inspections (
1225
+ id TEXT PRIMARY KEY,
1226
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1227
+ url TEXT NOT NULL,
1228
+ http_code INTEGER,
1229
+ in_index INTEGER,
1230
+ last_crawled_date TEXT,
1231
+ in_index_date TEXT,
1232
+ inspected_at TEXT NOT NULL,
1233
+ created_at TEXT NOT NULL
1234
+ )`,
1235
+ `CREATE INDEX IF NOT EXISTS idx_bing_inspect_project_url ON bing_url_inspections(project_id, url)`,
1236
+ `CREATE INDEX IF NOT EXISTS idx_bing_inspect_url_time ON bing_url_inspections(url, inspected_at)`,
1237
+ // v12: Bing Webmaster Tools — bing_keyword_stats table
1238
+ `CREATE TABLE IF NOT EXISTS bing_keyword_stats (
1239
+ id TEXT PRIMARY KEY,
1240
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1241
+ query TEXT NOT NULL,
1242
+ impressions INTEGER NOT NULL DEFAULT 0,
1243
+ clicks INTEGER NOT NULL DEFAULT 0,
1244
+ ctr TEXT NOT NULL DEFAULT '0',
1245
+ average_position TEXT NOT NULL DEFAULT '0',
1246
+ synced_at TEXT NOT NULL,
1247
+ created_at TEXT NOT NULL
1248
+ )`,
1249
+ `CREATE INDEX IF NOT EXISTS idx_bing_keyword_project ON bing_keyword_stats(project_id)`,
1250
+ `CREATE INDEX IF NOT EXISTS idx_bing_keyword_query ON bing_keyword_stats(query)`
1218
1251
  ];
1219
1252
  function migrate(db) {
1220
1253
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -1297,17 +1330,46 @@ function writeAuditLog(db, entry) {
1297
1330
  async function projectRoutes(app, opts) {
1298
1331
  app.put("/projects/:name", async (request, reply) => {
1299
1332
  const { name } = request.params;
1300
- const body = request.body;
1301
- if (!body || !body.displayName || !body.canonicalDomain || !body.country || !body.language) {
1302
- const err = validationError("Missing required fields: displayName, canonicalDomain, country, language");
1333
+ const parsedBody = projectUpsertRequestSchema.safeParse(request.body);
1334
+ if (!parsedBody.success) {
1335
+ const err = validationError("Invalid project payload", {
1336
+ issues: parsedBody.error.issues.map((issue) => ({
1337
+ path: issue.path.join("."),
1338
+ message: issue.message
1339
+ }))
1340
+ });
1303
1341
  return reply.status(err.statusCode).send(err.toJSON());
1304
1342
  }
1305
- if (body.ownedDomains !== void 0 && (!Array.isArray(body.ownedDomains) || body.ownedDomains.some((d) => typeof d !== "string" || d.trim() === ""))) {
1306
- const err = validationError("ownedDomains must be an array of non-empty strings");
1307
- return reply.status(err.statusCode).send(err.toJSON());
1343
+ const body = parsedBody.data;
1344
+ const validNames = opts.validProviderNames ?? [];
1345
+ if (validNames.length && body.providers?.length) {
1346
+ const invalid = body.providers.filter((p) => !validNames.includes(p));
1347
+ if (invalid.length) {
1348
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1349
+ invalidProviders: invalid,
1350
+ validProviders: validNames
1351
+ });
1352
+ return reply.status(err.statusCode).send(err.toJSON());
1353
+ }
1308
1354
  }
1309
1355
  const now = (/* @__PURE__ */ new Date()).toISOString();
1310
1356
  const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
1357
+ const existingLocations = existing ? JSON.parse(existing.locations || "[]") : [];
1358
+ const nextLocations = body.locations ?? existingLocations;
1359
+ const duplicateLabels = findDuplicateLocationLabels(nextLocations);
1360
+ if (duplicateLabels.length > 0) {
1361
+ const err = validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
1362
+ duplicateLabels
1363
+ });
1364
+ return reply.status(err.statusCode).send(err.toJSON());
1365
+ }
1366
+ const nextDefaultLocation = body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing?.defaultLocation ?? null;
1367
+ if (!hasLocationLabel(nextLocations, nextDefaultLocation)) {
1368
+ const err = validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
1369
+ defaultLocation: nextDefaultLocation
1370
+ });
1371
+ return reply.status(err.statusCode).send(err.toJSON());
1372
+ }
1311
1373
  if (existing) {
1312
1374
  app.db.update(projects).set({
1313
1375
  displayName: body.displayName,
@@ -1318,8 +1380,8 @@ async function projectRoutes(app, opts) {
1318
1380
  tags: JSON.stringify(body.tags ?? []),
1319
1381
  labels: JSON.stringify(body.labels ?? {}),
1320
1382
  providers: JSON.stringify(body.providers ?? []),
1321
- locations: JSON.stringify(body.locations ?? JSON.parse(existing.locations || "[]")),
1322
- defaultLocation: body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing.defaultLocation,
1383
+ locations: JSON.stringify(nextLocations),
1384
+ defaultLocation: nextDefaultLocation,
1323
1385
  configSource: body.configSource ?? "api",
1324
1386
  configRevision: existing.configRevision + 1,
1325
1387
  updatedAt: now
@@ -1346,8 +1408,8 @@ async function projectRoutes(app, opts) {
1346
1408
  tags: JSON.stringify(body.tags ?? []),
1347
1409
  labels: JSON.stringify(body.labels ?? {}),
1348
1410
  providers: JSON.stringify(body.providers ?? []),
1349
- locations: JSON.stringify(body.locations ?? []),
1350
- defaultLocation: body.defaultLocation ?? null,
1411
+ locations: JSON.stringify(nextLocations),
1412
+ defaultLocation: nextDefaultLocation,
1351
1413
  configSource: body.configSource ?? "api",
1352
1414
  configRevision: 1,
1353
1415
  createdAt: now,
@@ -1712,9 +1774,13 @@ async function keywordRoutes(app, opts) {
1712
1774
  const err = validationError('Body must contain a "provider" string');
1713
1775
  return reply.status(err.statusCode).send(err.toJSON());
1714
1776
  }
1715
- const provider = parseProviderName(body.provider);
1716
- if (!provider) {
1717
- const err = validationError(`Unknown provider "${body.provider}". Valid providers: gemini, openai, claude, local`);
1777
+ const provider = body.provider.trim().toLowerCase();
1778
+ const validNames = opts.validProviderNames ?? [];
1779
+ if (validNames.length && !validNames.includes(provider)) {
1780
+ const err = validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
1781
+ provider: body.provider,
1782
+ validProviders: validNames
1783
+ });
1718
1784
  return reply.status(err.statusCode).send(err.toJSON());
1719
1785
  }
1720
1786
  if (body.count !== void 0 && (typeof body.count !== "number" || !Number.isFinite(body.count) || !Number.isInteger(body.count))) {
@@ -1723,7 +1789,8 @@ async function keywordRoutes(app, opts) {
1723
1789
  }
1724
1790
  const count = Math.min(Math.max(body.count ?? 5, 1), 20);
1725
1791
  if (!opts.onGenerateKeywords) {
1726
- return reply.status(501).send({ error: "Key phrase generation is not supported in this deployment" });
1792
+ const err = notImplemented("Key phrase generation is not supported in this deployment");
1793
+ return reply.status(err.statusCode).send(err.toJSON());
1727
1794
  }
1728
1795
  const existingRows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1729
1796
  const existingKeywords = existingRows.map((r) => r.keyword);
@@ -1739,7 +1806,10 @@ async function keywordRoutes(app, opts) {
1739
1806
  } catch (err) {
1740
1807
  request.log.error({ err }, "Key phrase generation failed");
1741
1808
  return reply.status(500).send({
1742
- error: err instanceof Error ? err.message : "Failed to generate key phrases"
1809
+ error: {
1810
+ code: "INTERNAL_ERROR",
1811
+ message: err instanceof Error ? err.message : "Failed to generate key phrases"
1812
+ }
1743
1813
  });
1744
1814
  }
1745
1815
  });
@@ -1813,7 +1883,7 @@ function resolveProjectSafe2(app, name, reply) {
1813
1883
 
1814
1884
  // ../api-routes/src/runs.ts
1815
1885
  import crypto8 from "crypto";
1816
- import { eq as eq7, asc } from "drizzle-orm";
1886
+ import { eq as eq7, asc, desc } from "drizzle-orm";
1817
1887
 
1818
1888
  // ../api-routes/src/run-queue.ts
1819
1889
  import crypto7 from "crypto";
@@ -1860,12 +1930,19 @@ async function runRoutes(app, opts) {
1860
1930
  const trigger = request.body?.trigger ?? "manual";
1861
1931
  const rawProviders = request.body?.providers;
1862
1932
  if (rawProviders?.length) {
1863
- const parsed = rawProviders.map((p) => parseProviderName(p));
1864
- const invalid = rawProviders.filter((_, i) => !parsed[i]);
1865
- if (invalid.length) {
1866
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: gemini, openai, claude, local` } });
1933
+ const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
1934
+ const validNames = opts.validProviderNames ?? [];
1935
+ if (validNames.length) {
1936
+ const invalid = normalized.filter((p) => !validNames.includes(p));
1937
+ if (invalid.length) {
1938
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1939
+ invalidProviders: invalid,
1940
+ validProviders: validNames
1941
+ });
1942
+ return reply.status(err.statusCode).send(err.toJSON());
1943
+ }
1867
1944
  }
1868
- rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
1945
+ rawProviders.splice(0, rawProviders.length, ...normalized);
1869
1946
  }
1870
1947
  const providers = rawProviders?.length ? rawProviders : void 0;
1871
1948
  let resolvedLocation;
@@ -1944,7 +2021,9 @@ async function runRoutes(app, opts) {
1944
2021
  app.get("/projects/:name/runs", async (request, reply) => {
1945
2022
  const project = resolveProjectSafe3(app, request.params.name, reply);
1946
2023
  if (!project) return;
1947
- const rows = app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all();
2024
+ const parsedLimit = parseInt(request.query.limit ?? "", 10);
2025
+ const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
2026
+ const rows = limit == null ? app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
1948
2027
  return reply.send(rows.map(formatRun));
1949
2028
  });
1950
2029
  app.get("/runs", async (_request, reply) => {
@@ -1963,12 +2042,19 @@ async function runRoutes(app, opts) {
1963
2042
  }
1964
2043
  const rawProviders = request.body?.providers;
1965
2044
  if (rawProviders?.length) {
1966
- const parsed = rawProviders.map((p) => parseProviderName(p));
1967
- const invalid = rawProviders.filter((_, i) => !parsed[i]);
1968
- if (invalid.length) {
1969
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: gemini, openai, claude, local` } });
2045
+ const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
2046
+ const validNames = opts.validProviderNames ?? [];
2047
+ if (validNames.length) {
2048
+ const invalid = normalized.filter((p) => !validNames.includes(p));
2049
+ if (invalid.length) {
2050
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2051
+ invalidProviders: invalid,
2052
+ validProviders: validNames
2053
+ });
2054
+ return reply.status(err.statusCode).send(err.toJSON());
2055
+ }
1970
2056
  }
1971
- rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
2057
+ rawProviders.splice(0, rawProviders.length, ...normalized);
1972
2058
  }
1973
2059
  const providers = rawProviders?.length ? rawProviders : void 0;
1974
2060
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2371,6 +2457,23 @@ async function applyRoutes(app, opts) {
2371
2457
  return reply.status(err.statusCode).send(err.toJSON());
2372
2458
  }
2373
2459
  const config = parsed.data;
2460
+ const validNames = opts?.validProviderNames ?? [];
2461
+ if (validNames.length) {
2462
+ const allProviders = [
2463
+ ...config.spec.providers ?? [],
2464
+ ...config.spec.schedule?.providers ?? []
2465
+ ];
2466
+ if (allProviders.length) {
2467
+ const invalid = allProviders.filter((p) => !validNames.includes(p));
2468
+ if (invalid.length) {
2469
+ const err = validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2470
+ invalidProviders: [...new Set(invalid)],
2471
+ validProviders: validNames
2472
+ });
2473
+ return reply.status(err.statusCode).send(err.toJSON());
2474
+ }
2475
+ }
2476
+ }
2374
2477
  const now = (/* @__PURE__ */ new Date()).toISOString();
2375
2478
  const name = config.metadata.name;
2376
2479
  const existing = app.db.select().from(projects).where(eq8(projects.name, name)).get();
@@ -2578,16 +2681,16 @@ async function applyRoutes(app, opts) {
2578
2681
  }
2579
2682
 
2580
2683
  // ../api-routes/src/history.ts
2581
- import { eq as eq9, desc, inArray } from "drizzle-orm";
2684
+ import { eq as eq9, desc as desc2, inArray } from "drizzle-orm";
2582
2685
  async function historyRoutes(app) {
2583
2686
  app.get("/projects/:name/history", async (request, reply) => {
2584
2687
  const project = resolveProjectSafe4(app, request.params.name, reply);
2585
2688
  if (!project) return;
2586
- const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc(auditLog.createdAt)).all();
2689
+ const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc2(auditLog.createdAt)).all();
2587
2690
  return reply.send(rows.map(formatAuditEntry));
2588
2691
  });
2589
2692
  app.get("/history", async (_request, reply) => {
2590
- const rows = app.db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).all();
2693
+ const rows = app.db.select().from(auditLog).orderBy(desc2(auditLog.createdAt)).all();
2591
2694
  return reply.send(rows.map(formatAuditEntry));
2592
2695
  });
2593
2696
  app.get("/projects/:name/snapshots", async (request, reply) => {
@@ -2612,7 +2715,7 @@ async function historyRoutes(app) {
2612
2715
  competitorOverlap: querySnapshots.competitorOverlap,
2613
2716
  location: querySnapshots.location,
2614
2717
  createdAt: querySnapshots.createdAt
2615
- }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc(querySnapshots.createdAt)).all();
2718
+ }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc2(querySnapshots.createdAt)).all();
2616
2719
  const locationFilter = request.query.location;
2617
2720
  const filtered = locationFilter !== void 0 ? allSnapshots.filter((s) => s.location === (locationFilter || null)) : allSnapshots;
2618
2721
  const total = filtered.length;
@@ -2793,7 +2896,7 @@ function resolveProjectSafe4(app, name, reply) {
2793
2896
  }
2794
2897
 
2795
2898
  // ../api-routes/src/analytics.ts
2796
- import { eq as eq10, desc as desc2, inArray as inArray2 } from "drizzle-orm";
2899
+ import { eq as eq10, desc as desc3, inArray as inArray2 } from "drizzle-orm";
2797
2900
  async function analyticsRoutes(app) {
2798
2901
  app.get("/projects/:name/analytics/metrics", async (request, reply) => {
2799
2902
  const project = resolveProjectSafe5(app, request.params.name, reply);
@@ -2837,7 +2940,7 @@ async function analyticsRoutes(app) {
2837
2940
  if (!project) return;
2838
2941
  const window = parseWindow(request.query.window);
2839
2942
  const cutoff = windowCutoff(window);
2840
- const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc2(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
2943
+ const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
2841
2944
  if (!latestRun) {
2842
2945
  return reply.send({ cited: [], gap: [], uncited: [], runId: "", window });
2843
2946
  }
@@ -2919,7 +3022,7 @@ async function analyticsRoutes(app) {
2919
3022
  if (!project) return;
2920
3023
  const window = parseWindow(request.query.window);
2921
3024
  const cutoff = windowCutoff(window);
2922
- const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc2(runs.createdAt)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
3025
+ const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
2923
3026
  if (windowRuns.length === 0) {
2924
3027
  return reply.send({ overall: [], byKeyword: {}, runId: "", window });
2925
3028
  }
@@ -3035,14 +3138,16 @@ function computeBuckets(snapshots, projectRuns, bucketDays) {
3035
3138
  const startISO = start.toISOString();
3036
3139
  const endISO = end.toISOString();
3037
3140
  const inBucket = snapshots.filter((s) => s.createdAt >= startISO && s.createdAt < endISO);
3038
- const metric = computeProviderMetric(inBucket);
3039
- buckets.push({
3040
- startDate: startISO,
3041
- endDate: endISO,
3042
- citationRate: metric.citationRate,
3043
- cited: metric.cited,
3044
- total: metric.total
3045
- });
3141
+ if (inBucket.length > 0) {
3142
+ const metric = computeProviderMetric(inBucket);
3143
+ buckets.push({
3144
+ startDate: startISO,
3145
+ endDate: endISO,
3146
+ citationRate: metric.citationRate,
3147
+ cited: metric.cited,
3148
+ total: metric.total
3149
+ });
3150
+ }
3046
3151
  start = end;
3047
3152
  }
3048
3153
  return buckets;
@@ -3130,7 +3235,7 @@ var providerNameParameter = {
3130
3235
  in: "path",
3131
3236
  required: true,
3132
3237
  description: "Provider name.",
3133
- schema: { type: "string", enum: ["gemini", "openai", "claude", "local"] }
3238
+ schema: { type: "string", enum: ["gemini", "openai", "claude", "perplexity", "local"] }
3134
3239
  };
3135
3240
  var locationLabelParameter = {
3136
3241
  name: "label",
@@ -3439,7 +3544,7 @@ var routeCatalog = [
3439
3544
  type: "object",
3440
3545
  required: ["provider"],
3441
3546
  properties: {
3442
- provider: { type: "string", enum: ["gemini", "openai", "claude", "local"] },
3547
+ provider: { type: "string", enum: ["gemini", "openai", "claude", "perplexity", "local"] },
3443
3548
  count: integerSchema
3444
3549
  }
3445
3550
  }
@@ -3518,7 +3623,7 @@ var routeCatalog = [
3518
3623
  path: "/api/v1/projects/{name}/runs",
3519
3624
  summary: "List project runs",
3520
3625
  tags: ["runs"],
3521
- parameters: [nameParameter],
3626
+ parameters: [nameParameter, limitQueryParameter],
3522
3627
  responses: {
3523
3628
  200: { description: "Runs returned." }
3524
3629
  }
@@ -4601,31 +4706,40 @@ async function settingsRoutes(app, opts) {
4601
4706
  bing: opts.bing ?? { configured: false }
4602
4707
  }));
4603
4708
  app.put("/settings/providers/:name", async (request, reply) => {
4604
- const providerName = parseProviderName(request.params.name);
4605
4709
  const { apiKey, baseUrl, model, quota } = request.body ?? {};
4606
- if (!providerName) {
4607
- return reply.status(400).send({ error: `Invalid provider: ${request.params.name}. Must be one of: gemini, openai, claude, local` });
4710
+ const name = request.params.name;
4711
+ const adapters = opts.providerAdapters ?? [];
4712
+ const apiAdapters = adapters.filter((a) => a.mode === "api");
4713
+ const adapterInfo = apiAdapters.find((a) => a.name === name);
4714
+ if (!adapterInfo) {
4715
+ const validNames = apiAdapters.map((a) => a.name);
4716
+ const err = validationError(`Invalid provider: ${name}. Must be one of: ${validNames.join(", ")}`, {
4717
+ provider: name,
4718
+ validProviders: validNames
4719
+ });
4720
+ return reply.status(err.statusCode).send(err.toJSON());
4608
4721
  }
4609
- const name = providerName;
4610
4722
  if (name === "local") {
4611
4723
  if (!baseUrl || typeof baseUrl !== "string") {
4612
- return reply.status(400).send({ error: "baseUrl is required for local provider" });
4724
+ const err = validationError("baseUrl is required for local provider");
4725
+ return reply.status(err.statusCode).send(err.toJSON());
4613
4726
  }
4614
4727
  } else {
4615
4728
  if (!apiKey || typeof apiKey !== "string") {
4616
- return reply.status(400).send({ error: "apiKey is required" });
4729
+ const err = validationError("apiKey is required");
4730
+ return reply.status(err.statusCode).send(err.toJSON());
4617
4731
  }
4618
4732
  }
4619
4733
  if (model !== void 0) {
4620
- const registry = MODEL_REGISTRY[name];
4621
- if (!registry.validationPattern.test(model)) {
4734
+ if (!adapterInfo.modelValidationPattern.test(model)) {
4622
4735
  return reply.status(400).send({
4623
- error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "${name}" \u2014 ${registry.validationHint}` }
4736
+ error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "${name}" \u2014 ${adapterInfo.modelValidationHint}` }
4624
4737
  });
4625
4738
  }
4626
4739
  }
4627
4740
  if (!opts.onProviderUpdate) {
4628
- return reply.status(501).send({ error: "Provider configuration updates are not supported in this deployment" });
4741
+ const err = notImplemented("Provider configuration updates are not supported in this deployment");
4742
+ return reply.status(err.statusCode).send(err.toJSON());
4629
4743
  }
4630
4744
  if (quota !== void 0) {
4631
4745
  if (typeof quota !== "object" || quota === null) {
@@ -4642,7 +4756,12 @@ async function settingsRoutes(app, opts) {
4642
4756
  }
4643
4757
  const result = opts.onProviderUpdate(name, apiKey ?? "", model, baseUrl, quota);
4644
4758
  if (!result) {
4645
- return reply.status(500).send({ error: "Failed to update provider configuration" });
4759
+ return reply.status(500).send({
4760
+ error: {
4761
+ code: "INTERNAL_ERROR",
4762
+ message: "Failed to update provider configuration"
4763
+ }
4764
+ });
4646
4765
  }
4647
4766
  return result;
4648
4767
  });
@@ -4654,11 +4773,17 @@ async function settingsRoutes(app, opts) {
4654
4773
  });
4655
4774
  }
4656
4775
  if (!opts.onGoogleUpdate) {
4657
- return reply.status(501).send({ error: "Google OAuth configuration updates are not supported in this deployment" });
4776
+ const err = notImplemented("Google OAuth configuration updates are not supported in this deployment");
4777
+ return reply.status(err.statusCode).send(err.toJSON());
4658
4778
  }
4659
4779
  const result = opts.onGoogleUpdate(clientId, clientSecret);
4660
4780
  if (!result) {
4661
- return reply.status(500).send({ error: "Failed to update Google OAuth configuration" });
4781
+ return reply.status(500).send({
4782
+ error: {
4783
+ code: "INTERNAL_ERROR",
4784
+ message: "Failed to update Google OAuth configuration"
4785
+ }
4786
+ });
4662
4787
  }
4663
4788
  return result;
4664
4789
  });
@@ -4670,11 +4795,17 @@ async function settingsRoutes(app, opts) {
4670
4795
  });
4671
4796
  }
4672
4797
  if (!opts.onBingUpdate) {
4673
- return reply.status(501).send({ error: "Bing configuration updates are not supported in this deployment" });
4798
+ const err = notImplemented("Bing configuration updates are not supported in this deployment");
4799
+ return reply.status(err.statusCode).send(err.toJSON());
4674
4800
  }
4675
4801
  const result = opts.onBingUpdate(apiKey);
4676
4802
  if (!result) {
4677
- return reply.status(500).send({ error: "Failed to update Bing configuration" });
4803
+ return reply.status(500).send({
4804
+ error: {
4805
+ code: "INTERNAL_ERROR",
4806
+ message: "Failed to update Bing configuration"
4807
+ }
4808
+ });
4678
4809
  }
4679
4810
  return result;
4680
4811
  });
@@ -4684,7 +4815,8 @@ async function settingsRoutes(app, opts) {
4684
4815
  async function telemetryRoutes(app, opts) {
4685
4816
  app.get("/telemetry", async (_request, reply) => {
4686
4817
  if (!opts.getTelemetryStatus) {
4687
- return reply.status(501).send({ error: "Telemetry status is not available in this deployment" });
4818
+ const err = notImplemented("Telemetry status is not available in this deployment");
4819
+ return reply.status(err.statusCode).send(err.toJSON());
4688
4820
  }
4689
4821
  const status = opts.getTelemetryStatus();
4690
4822
  return {
@@ -4694,11 +4826,13 @@ async function telemetryRoutes(app, opts) {
4694
4826
  });
4695
4827
  app.put("/telemetry", async (request, reply) => {
4696
4828
  if (!opts.setTelemetryEnabled) {
4697
- return reply.status(501).send({ error: "Telemetry configuration is not available in this deployment" });
4829
+ const err = notImplemented("Telemetry configuration is not available in this deployment");
4830
+ return reply.status(err.statusCode).send(err.toJSON());
4698
4831
  }
4699
4832
  const { enabled } = request.body ?? {};
4700
4833
  if (typeof enabled !== "boolean") {
4701
- return reply.status(400).send({ error: "enabled (boolean) is required" });
4834
+ const err = validationError("enabled (boolean) is required");
4835
+ return reply.status(err.statusCode).send(err.toJSON());
4702
4836
  }
4703
4837
  opts.setTelemetryEnabled(enabled);
4704
4838
  const status = opts.getTelemetryStatus?.();
@@ -4716,11 +4850,27 @@ async function scheduleRoutes(app, opts) {
4716
4850
  app.put("/projects/:name/schedule", async (request, reply) => {
4717
4851
  const project = resolveProjectSafe6(app, request.params.name, reply);
4718
4852
  if (!project) return;
4719
- const { preset, cron: cron2, timezone = "UTC", providers = [], enabled = true } = request.body ?? {};
4720
- if (!preset && !cron2 || preset && cron2) {
4721
- return reply.status(400).send({
4722
- error: { code: "VALIDATION_ERROR", message: 'Exactly one of "preset" or "cron" must be provided' }
4853
+ const parsedBody = scheduleUpsertRequestSchema.safeParse(request.body);
4854
+ if (!parsedBody.success) {
4855
+ const err = validationError("Invalid schedule payload", {
4856
+ issues: parsedBody.error.issues.map((issue) => ({
4857
+ path: issue.path.join("."),
4858
+ message: issue.message
4859
+ }))
4723
4860
  });
4861
+ return reply.status(err.statusCode).send(err.toJSON());
4862
+ }
4863
+ const { preset, cron: cron2, timezone, providers, enabled } = parsedBody.data;
4864
+ const validNames = opts.validProviderNames ?? [];
4865
+ if (validNames.length && providers?.length) {
4866
+ const invalid = providers.filter((p) => !validNames.includes(p));
4867
+ if (invalid.length) {
4868
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
4869
+ invalidProviders: invalid,
4870
+ validProviders: validNames
4871
+ });
4872
+ return reply.status(err.statusCode).send(err.toJSON());
4873
+ }
4724
4874
  }
4725
4875
  if (!isValidTimezone(timezone)) {
4726
4876
  return reply.status(400).send({
@@ -4991,7 +5141,7 @@ function resolveProjectSafe7(app, name, reply) {
4991
5141
 
4992
5142
  // ../api-routes/src/google.ts
4993
5143
  import crypto13 from "crypto";
4994
- import { eq as eq13, and as and3, desc as desc3, sql as sql2 } from "drizzle-orm";
5144
+ import { eq as eq13, and as and3, desc as desc4, sql as sql2 } from "drizzle-orm";
4995
5145
 
4996
5146
  // ../integration-google/src/constants.ts
4997
5147
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -5432,7 +5582,7 @@ async function googleRoutes(app, opts) {
5432
5582
  if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
5433
5583
  if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
5434
5584
  if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
5435
- const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc3(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
5585
+ const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
5436
5586
  return rows.map((r) => ({
5437
5587
  date: r.date,
5438
5588
  query: r.query,
@@ -5510,7 +5660,7 @@ async function googleRoutes(app, opts) {
5510
5660
  const { url, limit } = request.query;
5511
5661
  const conditions = [eq13(gscUrlInspections.projectId, project.id)];
5512
5662
  if (url) conditions.push(eq13(gscUrlInspections.url, url));
5513
- const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc3(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
5663
+ const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
5514
5664
  return rows.map((r) => ({
5515
5665
  id: r.id,
5516
5666
  url: r.url,
@@ -5529,7 +5679,7 @@ async function googleRoutes(app, opts) {
5529
5679
  });
5530
5680
  app.get("/projects/:name/google/gsc/deindexed", async (request) => {
5531
5681
  const project = resolveProject(app.db, request.params.name);
5532
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5682
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc4(gscUrlInspections.inspectedAt)).all();
5533
5683
  const byUrl = /* @__PURE__ */ new Map();
5534
5684
  for (const row of allInspections) {
5535
5685
  const existing = byUrl.get(row.url);
@@ -5557,7 +5707,7 @@ async function googleRoutes(app, opts) {
5557
5707
  });
5558
5708
  app.get("/projects/:name/google/gsc/coverage", async (request) => {
5559
5709
  const project = resolveProject(app.db, request.params.name);
5560
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5710
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc4(gscUrlInspections.inspectedAt)).all();
5561
5711
  const latestByUrl = /* @__PURE__ */ new Map();
5562
5712
  const historyByUrl = /* @__PURE__ */ new Map();
5563
5713
  for (const row of allInspections) {
@@ -5649,7 +5799,7 @@ async function googleRoutes(app, opts) {
5649
5799
  const project = resolveProject(app.db, request.params.name);
5650
5800
  const parsed = parseInt(request.query.limit ?? "90", 10);
5651
5801
  const limit = Number.isNaN(parsed) || parsed <= 0 ? 90 : parsed;
5652
- const rows = app.db.select().from(gscCoverageSnapshots).where(eq13(gscCoverageSnapshots.projectId, project.id)).orderBy(desc3(gscCoverageSnapshots.date)).limit(limit).all();
5802
+ const rows = app.db.select().from(gscCoverageSnapshots).where(eq13(gscCoverageSnapshots.projectId, project.id)).orderBy(desc4(gscCoverageSnapshots.date)).limit(limit).all();
5653
5803
  return rows.map((r) => ({
5654
5804
  date: r.date,
5655
5805
  indexed: r.indexed,
@@ -5802,7 +5952,7 @@ async function googleRoutes(app, opts) {
5802
5952
  const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
5803
5953
  let urlsToNotify = request.body?.urls ?? [];
5804
5954
  if (request.body?.allUnindexed) {
5805
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5955
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc4(gscUrlInspections.inspectedAt)).all();
5806
5956
  const latestByUrl = /* @__PURE__ */ new Map();
5807
5957
  for (const row of allInspections) {
5808
5958
  if (!latestByUrl.has(row.url)) {
@@ -5877,7 +6027,7 @@ async function googleRoutes(app, opts) {
5877
6027
 
5878
6028
  // ../api-routes/src/bing.ts
5879
6029
  import crypto14 from "crypto";
5880
- import { eq as eq14, and as and4, desc as desc4 } from "drizzle-orm";
6030
+ import { eq as eq14, and as and4, desc as desc5 } from "drizzle-orm";
5881
6031
 
5882
6032
  // ../integration-bing/src/constants.ts
5883
6033
  var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
@@ -6081,7 +6231,7 @@ async function bingRoutes(app, opts) {
6081
6231
  const project = resolveProject(app.db, request.params.name);
6082
6232
  const conn = requireConnection(store, project.canonicalDomain, reply);
6083
6233
  if (!conn) return;
6084
- const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
6234
+ const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc5(bingUrlInspections.inspectedAt)).all();
6085
6235
  const latestByUrl = /* @__PURE__ */ new Map();
6086
6236
  for (const row of allInspections) {
6087
6237
  if (!latestByUrl.has(row.url)) {
@@ -6131,7 +6281,7 @@ async function bingRoutes(app, opts) {
6131
6281
  const project = resolveProject(app.db, request.params.name);
6132
6282
  const { url, limit } = request.query;
6133
6283
  const whereClause = url ? and4(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
6134
- const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc4(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
6284
+ const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc5(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
6135
6285
  return filtered.map((r) => ({
6136
6286
  id: r.id,
6137
6287
  url: r.url,
@@ -6193,7 +6343,7 @@ async function bingRoutes(app, opts) {
6193
6343
  }
6194
6344
  let urlsToSubmit = request.body?.urls ?? [];
6195
6345
  if (request.body?.allUnindexed) {
6196
- const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
6346
+ const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc5(bingUrlInspections.inspectedAt)).all();
6197
6347
  const latestByUrl = /* @__PURE__ */ new Map();
6198
6348
  for (const row of allInspections) {
6199
6349
  if (!latestByUrl.has(row.url)) {
@@ -6289,51 +6439,61 @@ async function cdpRoutes(app, opts) {
6289
6439
  const { snapshotId } = request.params;
6290
6440
  const snapshot = app.db.select({ screenshotPath: querySnapshots.screenshotPath }).from(querySnapshots).where(eq15(querySnapshots.id, snapshotId)).get();
6291
6441
  if (!snapshot?.screenshotPath) {
6292
- return reply.code(404).send({ error: "Screenshot not found" });
6442
+ const err = notFound("Screenshot", snapshotId);
6443
+ return reply.code(err.statusCode).send(err.toJSON());
6293
6444
  }
6294
6445
  const base = path2.resolve(getScreenshotDir());
6295
6446
  const fullPath = path2.resolve(path2.join(base, snapshot.screenshotPath));
6296
6447
  if (!fullPath.startsWith(base + path2.sep) && fullPath !== base) {
6297
- return reply.code(404).send({ error: "Screenshot not found" });
6448
+ const err = notFound("Screenshot", snapshotId);
6449
+ return reply.code(err.statusCode).send(err.toJSON());
6298
6450
  }
6299
6451
  if (!fs2.existsSync(fullPath)) {
6300
- return reply.code(404).send({ error: "Screenshot file not found on disk" });
6452
+ const err = notFound("Screenshot file", snapshotId);
6453
+ return reply.code(err.statusCode).send(err.toJSON());
6301
6454
  }
6302
6455
  const stream = fs2.createReadStream(fullPath);
6303
6456
  return reply.type("image/png").send(stream);
6304
6457
  });
6305
6458
  app.put("/settings/cdp", async (request, reply) => {
6306
6459
  if (!opts.onCdpConfigure) {
6307
- return reply.code(501).send({ error: "CDP configuration not supported in this deployment" });
6460
+ const err = notImplemented("CDP configuration not supported in this deployment");
6461
+ return reply.code(err.statusCode).send(err.toJSON());
6308
6462
  }
6309
6463
  const { host, port = 9222 } = request.body;
6310
6464
  if (!host || typeof host !== "string") {
6311
- return reply.code(400).send({ error: "host is required" });
6465
+ const err = validationError("host is required");
6466
+ return reply.code(err.statusCode).send(err.toJSON());
6312
6467
  }
6313
6468
  const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "::1"];
6314
6469
  if (!ALLOWED_HOSTS.includes(host)) {
6315
- return reply.code(400).send({ error: "host must be localhost, 127.0.0.1, or ::1" });
6470
+ const err = validationError("host must be localhost, 127.0.0.1, or ::1");
6471
+ return reply.code(err.statusCode).send(err.toJSON());
6316
6472
  }
6317
6473
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
6318
- return reply.code(400).send({ error: "port must be an integer between 1 and 65535" });
6474
+ const err = validationError("port must be an integer between 1 and 65535");
6475
+ return reply.code(err.statusCode).send(err.toJSON());
6319
6476
  }
6320
6477
  await opts.onCdpConfigure(host, port);
6321
6478
  return reply.code(200).send({ endpoint: `ws://${host}:${port}` });
6322
6479
  });
6323
6480
  app.get("/cdp/status", async (_request, reply) => {
6324
6481
  if (!opts.getCdpStatus) {
6325
- return reply.code(501).send({ error: "CDP not configured" });
6482
+ const err = notImplemented("CDP not configured");
6483
+ return reply.code(err.statusCode).send(err.toJSON());
6326
6484
  }
6327
6485
  const status = await opts.getCdpStatus();
6328
6486
  return reply.send(status);
6329
6487
  });
6330
6488
  app.post("/cdp/screenshot", async (request, reply) => {
6331
6489
  if (!opts.onCdpScreenshot) {
6332
- return reply.code(501).send({ error: "CDP not configured" });
6490
+ const err = notImplemented("CDP not configured");
6491
+ return reply.code(err.statusCode).send(err.toJSON());
6333
6492
  }
6334
6493
  const { query, targets } = request.body;
6335
6494
  if (!query || typeof query !== "string") {
6336
- return reply.code(400).send({ error: "query is required" });
6495
+ const err = validationError("query is required");
6496
+ return reply.code(err.statusCode).send(err.toJSON());
6337
6497
  }
6338
6498
  const results = await opts.onCdpScreenshot(query, targets);
6339
6499
  return reply.code(200).send({ results });
@@ -6344,7 +6504,10 @@ async function cdpRoutes(app, opts) {
6344
6504
  const project = resolveProject(app.db, request.params.name);
6345
6505
  const { runId } = request.params;
6346
6506
  const run = app.db.select().from(runs).where(and5(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
6347
- if (!run) return reply.code(404).send({ error: "Run not found" });
6507
+ if (!run) {
6508
+ const err = notFound("Run", runId);
6509
+ return reply.code(err.statusCode).send(err.toJSON());
6510
+ }
6348
6511
  const snapshots = app.db.select({
6349
6512
  id: querySnapshots.id,
6350
6513
  keywordId: querySnapshots.keywordId,
@@ -6465,15 +6628,21 @@ async function apiRoutes(app, opts) {
6465
6628
  await app.register(async (api) => {
6466
6629
  await api.register(openApiRoutes, opts.openApiInfo ?? {});
6467
6630
  await api.register(projectRoutes, {
6468
- onProjectDeleted: opts.onProjectDeleted
6631
+ onProjectDeleted: opts.onProjectDeleted,
6632
+ validProviderNames: opts.providerAdapters?.map((a) => a.name)
6469
6633
  });
6470
6634
  await api.register(keywordRoutes, {
6471
- onGenerateKeywords: opts.onGenerateKeywords
6635
+ onGenerateKeywords: opts.onGenerateKeywords,
6636
+ validProviderNames: opts.providerAdapters?.filter((a) => a.mode === "api").map((a) => a.name)
6472
6637
  });
6473
6638
  await api.register(competitorRoutes);
6474
- await api.register(runRoutes, { onRunCreated: opts.onRunCreated });
6639
+ await api.register(runRoutes, {
6640
+ onRunCreated: opts.onRunCreated,
6641
+ validProviderNames: opts.providerAdapters?.map((a) => a.name)
6642
+ });
6475
6643
  await api.register(applyRoutes, {
6476
6644
  onScheduleUpdated: opts.onScheduleUpdated,
6645
+ validProviderNames: opts.providerAdapters?.map((a) => a.name),
6477
6646
  onGoogleConnectionPropertyUpdated: (domain, connectionType, propertyId) => {
6478
6647
  opts.googleConnectionStore?.updateConnection(domain, connectionType, {
6479
6648
  propertyId,
@@ -6485,6 +6654,7 @@ async function apiRoutes(app, opts) {
6485
6654
  await api.register(analyticsRoutes);
6486
6655
  await api.register(settingsRoutes, {
6487
6656
  providerSummary: opts.providerSummary,
6657
+ providerAdapters: opts.providerAdapters,
6488
6658
  onProviderUpdate: opts.onProviderUpdate,
6489
6659
  google: opts.googleSettingsSummary,
6490
6660
  onGoogleUpdate: opts.onGoogleSettingsUpdate,
@@ -6492,7 +6662,8 @@ async function apiRoutes(app, opts) {
6492
6662
  onBingUpdate: opts.onBingSettingsUpdate
6493
6663
  });
6494
6664
  await api.register(scheduleRoutes, {
6495
- onScheduleUpdated: opts.onScheduleUpdated
6665
+ onScheduleUpdated: opts.onScheduleUpdated,
6666
+ validProviderNames: opts.providerAdapters?.map((a) => a.name)
6496
6667
  });
6497
6668
  await api.register(notificationRoutes);
6498
6669
  await api.register(telemetryRoutes, {
@@ -6520,11 +6691,12 @@ async function apiRoutes(app, opts) {
6520
6691
 
6521
6692
  // ../provider-gemini/src/normalize.ts
6522
6693
  import { GoogleGenerativeAI } from "@google/generative-ai";
6523
- var DEFAULT_MODEL = getDefaultModel("gemini");
6694
+ var DEFAULT_MODEL = "gemini-3-flash";
6695
+ var VALIDATION_PATTERN = /^gemini-/;
6524
6696
  function resolveModel(config) {
6525
6697
  const m = config.model;
6526
6698
  if (!m) return DEFAULT_MODEL;
6527
- if (isValidModelName("gemini", m)) return m;
6699
+ if (VALIDATION_PATTERN.test(m)) return m;
6528
6700
  console.warn(
6529
6701
  `[provider-gemini] Invalid model name "${m}" \u2014 this provider uses the Gemini AI Studio API (generativelanguage.googleapis.com) which only accepts "gemini-*" model names. Falling back to ${DEFAULT_MODEL}.`
6530
6702
  );
@@ -6535,7 +6707,7 @@ function validateConfig(config) {
6535
6707
  return { ok: false, provider: "gemini", message: "missing api key" };
6536
6708
  }
6537
6709
  const model = resolveModel(config);
6538
- const warning = config.model && !isValidModelName("gemini", config.model) ? ` (invalid model "${config.model}" replaced with default)` : "";
6710
+ const warning = config.model && !VALIDATION_PATTERN.test(config.model) ? ` (invalid model "${config.model}" replaced with default)` : "";
6539
6711
  return {
6540
6712
  ok: true,
6541
6713
  provider: "gemini",
@@ -6719,12 +6891,26 @@ function toGeminiConfig(config) {
6719
6891
  }
6720
6892
  var geminiAdapter = {
6721
6893
  name: "gemini",
6722
- validateConfig(config) {
6723
- const result = validateConfig(toGeminiConfig(config));
6724
- return {
6725
- ok: result.ok,
6726
- provider: "gemini",
6727
- message: result.message,
6894
+ displayName: "Gemini",
6895
+ mode: "api",
6896
+ keyUrl: "https://aistudio.google.com/apikey",
6897
+ modelRegistry: {
6898
+ defaultModel: "gemini-3-flash",
6899
+ validationPattern: /^gemini-/,
6900
+ validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
6901
+ knownModels: [
6902
+ { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
6903
+ { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
6904
+ { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
6905
+ { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
6906
+ ]
6907
+ },
6908
+ validateConfig(config) {
6909
+ const result = validateConfig(toGeminiConfig(config));
6910
+ return {
6911
+ ok: result.ok,
6912
+ provider: "gemini",
6913
+ message: result.message,
6728
6914
  model: result.model
6729
6915
  };
6730
6916
  },
@@ -6777,7 +6963,7 @@ var geminiAdapter = {
6777
6963
 
6778
6964
  // ../provider-openai/src/normalize.ts
6779
6965
  import OpenAI from "openai";
6780
- var DEFAULT_MODEL2 = getDefaultModel("openai");
6966
+ var DEFAULT_MODEL2 = "gpt-5.4";
6781
6967
  function validateConfig2(config) {
6782
6968
  if (!config.apiKey || config.apiKey.length === 0) {
6783
6969
  return { ok: false, provider: "openai", message: "missing api key" };
@@ -6971,6 +7157,22 @@ function toOpenAIConfig(config) {
6971
7157
  }
6972
7158
  var openaiAdapter = {
6973
7159
  name: "openai",
7160
+ displayName: "OpenAI",
7161
+ mode: "api",
7162
+ keyUrl: "https://platform.openai.com/api-keys",
7163
+ modelRegistry: {
7164
+ defaultModel: "gpt-5.4",
7165
+ validationPattern: /^(gpt-|o\d)/,
7166
+ validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
7167
+ knownModels: [
7168
+ { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
7169
+ { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
7170
+ { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
7171
+ { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
7172
+ { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
7173
+ { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
7174
+ ]
7175
+ },
6974
7176
  validateConfig(config) {
6975
7177
  const result = validateConfig2(toOpenAIConfig(config));
6976
7178
  return {
@@ -7029,7 +7231,7 @@ var openaiAdapter = {
7029
7231
 
7030
7232
  // ../provider-claude/src/normalize.ts
7031
7233
  import Anthropic from "@anthropic-ai/sdk";
7032
- var DEFAULT_MODEL3 = getDefaultModel("claude");
7234
+ var DEFAULT_MODEL3 = "claude-sonnet-4-6";
7033
7235
  function validateConfig3(config) {
7034
7236
  if (!config.apiKey || config.apiKey.length === 0) {
7035
7237
  return { ok: false, provider: "claude", message: "missing api key" };
@@ -7217,6 +7419,19 @@ function toClaudeConfig(config) {
7217
7419
  }
7218
7420
  var claudeAdapter = {
7219
7421
  name: "claude",
7422
+ displayName: "Claude",
7423
+ mode: "api",
7424
+ keyUrl: "https://platform.claude.com/settings/keys",
7425
+ modelRegistry: {
7426
+ defaultModel: "claude-sonnet-4-6",
7427
+ validationPattern: /^claude-/,
7428
+ validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
7429
+ knownModels: [
7430
+ { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
7431
+ { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
7432
+ { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
7433
+ ]
7434
+ },
7220
7435
  validateConfig(config) {
7221
7436
  const result = validateConfig3(toClaudeConfig(config));
7222
7437
  return {
@@ -7275,7 +7490,7 @@ var claudeAdapter = {
7275
7490
 
7276
7491
  // ../provider-local/src/normalize.ts
7277
7492
  import OpenAI2 from "openai";
7278
- var DEFAULT_MODEL4 = getDefaultModel("local");
7493
+ var DEFAULT_MODEL4 = "llama3";
7279
7494
  function validateConfig4(config) {
7280
7495
  if (!config.baseUrl || config.baseUrl.length === 0) {
7281
7496
  return { ok: false, provider: "local", message: "missing base URL" };
@@ -7404,6 +7619,16 @@ function toLocalConfig(config) {
7404
7619
  }
7405
7620
  var localAdapter = {
7406
7621
  name: "local",
7622
+ displayName: "Local",
7623
+ mode: "api",
7624
+ modelRegistry: {
7625
+ defaultModel: "llama3",
7626
+ validationPattern: /./,
7627
+ validationHint: "any model name accepted",
7628
+ knownModels: [
7629
+ { id: "llama3", displayName: "Llama 3", tier: "standard" }
7630
+ ]
7631
+ },
7407
7632
  validateConfig(config) {
7408
7633
  const result = validateConfig4(toLocalConfig(config));
7409
7634
  return {
@@ -7920,6 +8145,16 @@ function getScreenshotDir2() {
7920
8145
  }
7921
8146
  var cdpChatgptAdapter = {
7922
8147
  name: "cdp:chatgpt",
8148
+ displayName: "ChatGPT (Browser)",
8149
+ mode: "browser",
8150
+ modelRegistry: {
8151
+ defaultModel: "chatgpt-web",
8152
+ validationPattern: /./,
8153
+ validationHint: "model is detected from the ChatGPT web UI",
8154
+ knownModels: [
8155
+ { id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
8156
+ ]
8157
+ },
7923
8158
  validateConfig(config) {
7924
8159
  if (!config.cdpEndpoint) {
7925
8160
  return {
@@ -8000,6 +8235,215 @@ var cdpChatgptAdapter = {
8000
8235
  }
8001
8236
  };
8002
8237
 
8238
+ // ../provider-perplexity/src/normalize.ts
8239
+ import OpenAI3 from "openai";
8240
+ var DEFAULT_MODEL5 = "sonar";
8241
+ var BASE_URL = "https://api.perplexity.ai";
8242
+ function validateConfig5(config) {
8243
+ if (!config.apiKey || config.apiKey.length === 0) {
8244
+ return { ok: false, provider: "perplexity", message: "missing api key" };
8245
+ }
8246
+ return {
8247
+ ok: true,
8248
+ provider: "perplexity",
8249
+ message: "config valid",
8250
+ model: config.model ?? DEFAULT_MODEL5
8251
+ };
8252
+ }
8253
+ async function healthcheck5(config) {
8254
+ const validation = validateConfig5(config);
8255
+ if (!validation.ok) return validation;
8256
+ try {
8257
+ const client = new OpenAI3({ apiKey: config.apiKey, baseURL: BASE_URL });
8258
+ const response = await client.chat.completions.create({
8259
+ model: config.model ?? DEFAULT_MODEL5,
8260
+ messages: [{ role: "user", content: 'Say "ok"' }]
8261
+ });
8262
+ const text2 = response.choices[0]?.message?.content ?? "";
8263
+ return {
8264
+ ok: text2.length > 0,
8265
+ provider: "perplexity",
8266
+ message: text2.length > 0 ? "perplexity api key verified" : "empty response from perplexity",
8267
+ model: config.model ?? DEFAULT_MODEL5
8268
+ };
8269
+ } catch (err) {
8270
+ return {
8271
+ ok: false,
8272
+ provider: "perplexity",
8273
+ message: err instanceof Error ? err.message : String(err),
8274
+ model: config.model ?? DEFAULT_MODEL5
8275
+ };
8276
+ }
8277
+ }
8278
+ async function executeTrackedQuery5(input) {
8279
+ const model = input.config.model ?? DEFAULT_MODEL5;
8280
+ const client = new OpenAI3({ apiKey: input.config.apiKey, baseURL: BASE_URL });
8281
+ const response = await client.chat.completions.create({
8282
+ model,
8283
+ messages: [
8284
+ { role: "user", content: input.keyword }
8285
+ ]
8286
+ });
8287
+ const rawResponse = responseToRecord4(response);
8288
+ const citations = extractCitations(rawResponse);
8289
+ const groundingSources = citations.map((url) => ({
8290
+ uri: url,
8291
+ title: ""
8292
+ }));
8293
+ return {
8294
+ provider: "perplexity",
8295
+ rawResponse,
8296
+ model,
8297
+ groundingSources,
8298
+ searchQueries: [input.keyword]
8299
+ };
8300
+ }
8301
+ function normalizeResult6(raw) {
8302
+ const answerText = extractAnswerText3(raw.rawResponse);
8303
+ const citedDomains = extractCitedDomains5(raw.groundingSources);
8304
+ return {
8305
+ provider: "perplexity",
8306
+ answerText,
8307
+ citedDomains,
8308
+ groundingSources: raw.groundingSources,
8309
+ searchQueries: raw.searchQueries
8310
+ };
8311
+ }
8312
+ function extractCitations(rawResponse) {
8313
+ if (Array.isArray(rawResponse.citations)) {
8314
+ return rawResponse.citations.filter((c) => typeof c === "string");
8315
+ }
8316
+ const apiResponse = rawResponse.apiResponse;
8317
+ if (apiResponse !== null && typeof apiResponse === "object" && !Array.isArray(apiResponse)) {
8318
+ const nested = apiResponse.citations;
8319
+ if (Array.isArray(nested)) {
8320
+ return nested.filter((c) => typeof c === "string");
8321
+ }
8322
+ }
8323
+ return [];
8324
+ }
8325
+ function extractAnswerText3(rawResponse) {
8326
+ try {
8327
+ const choices = rawResponse.choices;
8328
+ if (!choices?.length) return "";
8329
+ return choices[0].message?.content ?? "";
8330
+ } catch {
8331
+ return "";
8332
+ }
8333
+ }
8334
+ function extractCitedDomains5(groundingSources) {
8335
+ const domains = /* @__PURE__ */ new Set();
8336
+ for (const source of groundingSources) {
8337
+ const domain = extractDomainFromUri4(source.uri);
8338
+ if (domain) domains.add(domain);
8339
+ }
8340
+ return [...domains];
8341
+ }
8342
+ function extractDomainFromUri4(uri) {
8343
+ try {
8344
+ const url = new URL(uri);
8345
+ return url.hostname.replace(/^www\./, "");
8346
+ } catch {
8347
+ return null;
8348
+ }
8349
+ }
8350
+ async function generateText5(prompt, config) {
8351
+ const model = config.model ?? DEFAULT_MODEL5;
8352
+ const client = new OpenAI3({ apiKey: config.apiKey, baseURL: BASE_URL });
8353
+ const response = await client.chat.completions.create({
8354
+ model,
8355
+ messages: [{ role: "user", content: prompt }]
8356
+ });
8357
+ return response.choices[0]?.message?.content ?? "";
8358
+ }
8359
+ function responseToRecord4(response) {
8360
+ try {
8361
+ return JSON.parse(JSON.stringify(response));
8362
+ } catch {
8363
+ return { error: "failed to serialize response" };
8364
+ }
8365
+ }
8366
+
8367
+ // ../provider-perplexity/src/adapter.ts
8368
+ function toPerplexityConfig(config) {
8369
+ return {
8370
+ apiKey: config.apiKey ?? "",
8371
+ model: config.model,
8372
+ quotaPolicy: config.quotaPolicy
8373
+ };
8374
+ }
8375
+ var perplexityAdapter = {
8376
+ name: "perplexity",
8377
+ displayName: "Perplexity",
8378
+ mode: "api",
8379
+ keyUrl: "https://www.perplexity.ai/settings/api",
8380
+ modelRegistry: {
8381
+ defaultModel: "sonar",
8382
+ validationPattern: /^sonar/,
8383
+ validationHint: "expected a sonar model (e.g. sonar, sonar-pro, sonar-reasoning)",
8384
+ knownModels: [
8385
+ { id: "sonar", displayName: "Sonar", tier: "standard" },
8386
+ { id: "sonar-pro", displayName: "Sonar Pro", tier: "flagship" },
8387
+ { id: "sonar-reasoning", displayName: "Sonar Reasoning", tier: "flagship" },
8388
+ { id: "sonar-reasoning-pro", displayName: "Sonar Reasoning Pro", tier: "flagship" }
8389
+ ]
8390
+ },
8391
+ validateConfig(config) {
8392
+ const result = validateConfig5(toPerplexityConfig(config));
8393
+ return {
8394
+ ok: result.ok,
8395
+ provider: "perplexity",
8396
+ message: result.message,
8397
+ model: result.model
8398
+ };
8399
+ },
8400
+ async healthcheck(config) {
8401
+ const result = await healthcheck5(toPerplexityConfig(config));
8402
+ return {
8403
+ ok: result.ok,
8404
+ provider: "perplexity",
8405
+ message: result.message,
8406
+ model: result.model
8407
+ };
8408
+ },
8409
+ async executeTrackedQuery(input, config) {
8410
+ const raw = await executeTrackedQuery5({
8411
+ keyword: input.keyword,
8412
+ canonicalDomains: input.canonicalDomains,
8413
+ competitorDomains: input.competitorDomains,
8414
+ config: toPerplexityConfig(config),
8415
+ location: input.location
8416
+ });
8417
+ return {
8418
+ provider: "perplexity",
8419
+ rawResponse: raw.rawResponse,
8420
+ model: raw.model,
8421
+ groundingSources: raw.groundingSources,
8422
+ searchQueries: raw.searchQueries
8423
+ };
8424
+ },
8425
+ normalizeResult(raw) {
8426
+ const perplexityRaw = {
8427
+ provider: "perplexity",
8428
+ rawResponse: raw.rawResponse,
8429
+ model: raw.model,
8430
+ groundingSources: raw.groundingSources,
8431
+ searchQueries: raw.searchQueries
8432
+ };
8433
+ const normalized = normalizeResult6(perplexityRaw);
8434
+ return {
8435
+ provider: "perplexity",
8436
+ answerText: normalized.answerText,
8437
+ citedDomains: normalized.citedDomains,
8438
+ groundingSources: normalized.groundingSources,
8439
+ searchQueries: normalized.searchQueries
8440
+ };
8441
+ },
8442
+ async generateText(prompt, config) {
8443
+ return generateText5(prompt, toPerplexityConfig(config));
8444
+ }
8445
+ };
8446
+
8003
8447
  // src/google-config.ts
8004
8448
  function ensureConnections(config) {
8005
8449
  if (!config.google) config.google = {};
@@ -8078,7 +8522,75 @@ import crypto15 from "crypto";
8078
8522
  import fs4 from "fs";
8079
8523
  import path5 from "path";
8080
8524
  import os4 from "os";
8081
- import { eq as eq16, inArray as inArray3 } from "drizzle-orm";
8525
+ import { and as and6, eq as eq16, inArray as inArray3 } from "drizzle-orm";
8526
+ var RunCancelledError = class extends Error {
8527
+ constructor(runId) {
8528
+ super(`Run ${runId} was cancelled`);
8529
+ this.name = "RunCancelledError";
8530
+ }
8531
+ };
8532
+ var ProviderExecutionGate = class {
8533
+ constructor(maxConcurrency, maxPerMinute) {
8534
+ this.maxConcurrency = maxConcurrency;
8535
+ this.maxPerMinute = maxPerMinute;
8536
+ }
8537
+ window = [];
8538
+ waiters = [];
8539
+ rateLimitChain = Promise.resolve();
8540
+ inFlight = 0;
8541
+ async run(task) {
8542
+ await this.acquire();
8543
+ try {
8544
+ await this.waitForRateLimit();
8545
+ return await task();
8546
+ } finally {
8547
+ this.release();
8548
+ }
8549
+ }
8550
+ async acquire() {
8551
+ if (this.inFlight < Math.max(1, this.maxConcurrency)) {
8552
+ this.inFlight++;
8553
+ return;
8554
+ }
8555
+ await new Promise((resolve) => {
8556
+ this.waiters.push(resolve);
8557
+ });
8558
+ this.inFlight++;
8559
+ }
8560
+ release() {
8561
+ this.inFlight = Math.max(0, this.inFlight - 1);
8562
+ const next = this.waiters.shift();
8563
+ next?.();
8564
+ }
8565
+ async waitForRateLimit() {
8566
+ let releaseChain;
8567
+ const previousChain = this.rateLimitChain;
8568
+ this.rateLimitChain = new Promise((resolve) => {
8569
+ releaseChain = resolve;
8570
+ });
8571
+ await previousChain;
8572
+ try {
8573
+ const now = Date.now();
8574
+ const windowStart = now - 6e4;
8575
+ while (this.window.length > 0 && this.window[0] < windowStart) {
8576
+ this.window.shift();
8577
+ }
8578
+ if (this.window.length >= this.maxPerMinute) {
8579
+ const oldestInWindow = this.window[0];
8580
+ const waitMs = oldestInWindow + 6e4 - now + 50;
8581
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
8582
+ const nowAfterWait = Date.now();
8583
+ const newWindowStart = nowAfterWait - 6e4;
8584
+ while (this.window.length > 0 && this.window[0] < newWindowStart) {
8585
+ this.window.shift();
8586
+ }
8587
+ }
8588
+ this.window.push(Date.now());
8589
+ } finally {
8590
+ releaseChain?.();
8591
+ }
8592
+ }
8593
+ };
8082
8594
  var JobRunner = class {
8083
8595
  db;
8084
8596
  registry;
@@ -8099,13 +8611,34 @@ var JobRunner = class {
8099
8611
  async executeRun(runId, projectId, providerOverride, locationOverride) {
8100
8612
  const now = (/* @__PURE__ */ new Date()).toISOString();
8101
8613
  const startTime = Date.now();
8614
+ let runLocation;
8615
+ let activeProviders = [];
8616
+ let projectKeywords = [];
8617
+ const providerDispatchCounts = /* @__PURE__ */ new Map();
8102
8618
  try {
8103
- this.db.update(runs).set({ status: "running", startedAt: now }).where(eq16(runs.id, runId)).run();
8619
+ const existingRun = this.getRunState(runId);
8620
+ if (!existingRun) {
8621
+ throw new Error(`Run ${runId} not found`);
8622
+ }
8623
+ if (existingRun.status === "cancelled") {
8624
+ this.handleCancelledRun(runId, projectId, startTime, {
8625
+ providerCount: 0,
8626
+ providers: [],
8627
+ keywordCount: 0
8628
+ });
8629
+ return;
8630
+ }
8631
+ if (existingRun.status !== "queued" && existingRun.status !== "running") {
8632
+ throw new Error(`Run ${runId} is not executable from status '${existingRun.status}'`);
8633
+ }
8634
+ if (existingRun.status === "queued") {
8635
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(and6(eq16(runs.id, runId), eq16(runs.status, "queued"))).run();
8636
+ }
8637
+ this.throwIfRunCancelled(runId);
8104
8638
  const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
8105
8639
  if (!project) {
8106
8640
  throw new Error(`Project ${projectId} not found`);
8107
8641
  }
8108
- let runLocation;
8109
8642
  if (locationOverride === null) {
8110
8643
  runLocation = void 0;
8111
8644
  } else if (locationOverride) {
@@ -8117,16 +8650,26 @@ var JobRunner = class {
8117
8650
  }
8118
8651
  }
8119
8652
  const projectProviders = providerOverride ?? JSON.parse(project.providers || "[]");
8120
- const activeProviders = this.registry.getForProject(projectProviders);
8653
+ activeProviders = this.registry.getForProject(projectProviders);
8121
8654
  if (activeProviders.length === 0) {
8122
8655
  throw new Error("No providers configured. Add at least one provider API key.");
8123
8656
  }
8124
8657
  console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
8125
- const projectKeywords = this.db.select().from(keywords).where(eq16(keywords.projectId, projectId)).all();
8658
+ projectKeywords = this.db.select().from(keywords).where(eq16(keywords.projectId, projectId)).all();
8126
8659
  const projectCompetitors = this.db.select().from(competitors).where(eq16(competitors.projectId, projectId)).all();
8127
8660
  const competitorDomains = projectCompetitors.map((c) => c.domain);
8661
+ const allDomains = effectiveDomains({
8662
+ canonicalDomain: project.canonicalDomain,
8663
+ ownedDomains: JSON.parse(project.ownedDomains || "[]")
8664
+ });
8665
+ const executionContext = {
8666
+ providerCount: activeProviders.length,
8667
+ providers: activeProviders.map((provider) => provider.adapter.name),
8668
+ keywordCount: projectKeywords.length,
8669
+ ...runLocation ? { location: runLocation.label } : {}
8670
+ };
8128
8671
  const queriesPerProvider = projectKeywords.length;
8129
- const todayPeriod = getCurrentPeriod();
8672
+ const todayPeriod = getCurrentUsageDay();
8130
8673
  for (const p of activeProviders) {
8131
8674
  const providerScope = `${projectId}:${p.adapter.name}`;
8132
8675
  const providerUsage = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
@@ -8137,27 +8680,31 @@ var JobRunner = class {
8137
8680
  );
8138
8681
  }
8139
8682
  }
8140
- const minuteWindows = /* @__PURE__ */ new Map();
8141
- for (const p of activeProviders) {
8142
- minuteWindows.set(p.adapter.name, []);
8683
+ const executionGates = /* @__PURE__ */ new Map();
8684
+ for (const provider of activeProviders) {
8685
+ executionGates.set(
8686
+ provider.adapter.name,
8687
+ new ProviderExecutionGate(
8688
+ provider.config.quotaPolicy.maxConcurrency,
8689
+ provider.config.quotaPolicy.maxRequestsPerMinute
8690
+ )
8691
+ );
8143
8692
  }
8144
8693
  const providerErrors = /* @__PURE__ */ new Map();
8145
8694
  let totalSnapshotsInserted = 0;
8146
8695
  const apiProviders = activeProviders.filter((p) => !isBrowserProvider(p.adapter.name));
8147
8696
  const browserProviders = activeProviders.filter((p) => isBrowserProvider(p.adapter.name));
8148
- for (const kw of projectKeywords) {
8149
- const providerPromises = apiProviders.map(async (registeredProvider) => {
8150
- const { adapter, config } = registeredProvider;
8151
- const providerName = adapter.name;
8152
- try {
8153
- await this.waitForRateLimit(
8154
- minuteWindows.get(providerName),
8155
- config.quotaPolicy.maxRequestsPerMinute
8156
- );
8157
- const allDomains = effectiveDomains({
8158
- canonicalDomain: project.canonicalDomain,
8159
- ownedDomains: JSON.parse(project.ownedDomains || "[]")
8160
- });
8697
+ const processKeywordForProvider = async (registeredProvider, kw) => {
8698
+ const { adapter, config } = registeredProvider;
8699
+ const providerName = adapter.name;
8700
+ const gate = executionGates.get(providerName);
8701
+ if (!gate) {
8702
+ throw new Error(`Missing execution gate for provider ${providerName}`);
8703
+ }
8704
+ try {
8705
+ await gate.run(async () => {
8706
+ this.throwIfRunCancelled(runId);
8707
+ providerDispatchCounts.set(providerName, (providerDispatchCounts.get(providerName) ?? 0) + 1);
8161
8708
  const raw = await adapter.executeTrackedQuery(
8162
8709
  {
8163
8710
  keyword: kw.keyword,
@@ -8167,6 +8714,7 @@ var JobRunner = class {
8167
8714
  },
8168
8715
  config
8169
8716
  );
8717
+ this.throwIfRunCancelled(runId);
8170
8718
  const normalized = adapter.normalizeResult(raw);
8171
8719
  console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, domains=${JSON.stringify(allDomains)}`);
8172
8720
  const citationState = determineCitationState(normalized, allDomains);
@@ -8222,96 +8770,29 @@ var JobRunner = class {
8222
8770
  }
8223
8771
  totalSnapshotsInserted++;
8224
8772
  console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" \u2192 ${citationState}`);
8225
- } catch (err) {
8226
- const msg = err instanceof Error ? err.message : String(err);
8227
- console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
8228
- providerErrors.set(providerName, msg);
8773
+ });
8774
+ } catch (err) {
8775
+ if (err instanceof RunCancelledError) {
8776
+ throw err;
8229
8777
  }
8230
- });
8231
- await Promise.all(providerPromises);
8232
- for (const registeredProvider of browserProviders) {
8233
- const { adapter, config } = registeredProvider;
8234
- const providerName = adapter.name;
8235
- try {
8236
- await this.waitForRateLimit(
8237
- minuteWindows.get(providerName),
8238
- config.quotaPolicy.maxRequestsPerMinute
8239
- );
8240
- const allDomains = effectiveDomains({
8241
- canonicalDomain: project.canonicalDomain,
8242
- ownedDomains: JSON.parse(project.ownedDomains || "[]")
8243
- });
8244
- const raw = await adapter.executeTrackedQuery(
8245
- {
8246
- keyword: kw.keyword,
8247
- canonicalDomains: allDomains,
8248
- competitorDomains,
8249
- location: runLocation
8250
- },
8251
- config
8252
- );
8253
- const normalized = adapter.normalizeResult(raw);
8254
- console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, domains=${JSON.stringify(allDomains)}`);
8255
- const citationState = determineCitationState(normalized, allDomains);
8256
- const overlap = computeCompetitorOverlap(normalized, competitorDomains);
8257
- let screenshotRelPath = null;
8258
- if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
8259
- const snapshotId = crypto15.randomUUID();
8260
- const screenshotDir = path5.join(os4.homedir(), ".canonry", "screenshots", runId);
8261
- if (!fs4.existsSync(screenshotDir)) fs4.mkdirSync(screenshotDir, { recursive: true });
8262
- const destPath = path5.join(screenshotDir, `${snapshotId}.png`);
8263
- fs4.renameSync(raw.screenshotPath, destPath);
8264
- screenshotRelPath = `${runId}/${snapshotId}.png`;
8265
- this.db.insert(querySnapshots).values({
8266
- id: snapshotId,
8267
- runId,
8268
- keywordId: kw.id,
8269
- provider: providerName,
8270
- model: raw.model,
8271
- citationState,
8272
- answerText: normalized.answerText,
8273
- citedDomains: JSON.stringify(normalized.citedDomains),
8274
- competitorOverlap: JSON.stringify(overlap),
8275
- location: runLocation?.label ?? null,
8276
- screenshotPath: screenshotRelPath,
8277
- rawResponse: JSON.stringify({
8278
- model: raw.model,
8279
- groundingSources: normalized.groundingSources,
8280
- searchQueries: normalized.searchQueries,
8281
- apiResponse: raw.rawResponse
8282
- }),
8283
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
8284
- }).run();
8285
- } else {
8286
- this.db.insert(querySnapshots).values({
8287
- id: crypto15.randomUUID(),
8288
- runId,
8289
- keywordId: kw.id,
8290
- provider: providerName,
8291
- model: raw.model,
8292
- citationState,
8293
- answerText: normalized.answerText,
8294
- citedDomains: JSON.stringify(normalized.citedDomains),
8295
- competitorOverlap: JSON.stringify(overlap),
8296
- location: runLocation?.label ?? null,
8297
- rawResponse: JSON.stringify({
8298
- model: raw.model,
8299
- groundingSources: normalized.groundingSources,
8300
- searchQueries: normalized.searchQueries,
8301
- apiResponse: raw.rawResponse
8302
- }),
8303
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
8304
- }).run();
8305
- }
8306
- totalSnapshotsInserted++;
8307
- console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" \u2192 ${citationState}`);
8308
- } catch (err) {
8309
- const msg = err instanceof Error ? err.message : String(err);
8310
- console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
8778
+ const msg = err instanceof Error ? err.message : String(err);
8779
+ console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
8780
+ if (!providerErrors.has(providerName)) {
8311
8781
  providerErrors.set(providerName, msg);
8312
8782
  }
8313
8783
  }
8784
+ };
8785
+ await Promise.all(apiProviders.map(async (registeredProvider) => {
8786
+ await Promise.all(projectKeywords.map(async (kw) => {
8787
+ await processKeywordForProvider(registeredProvider, kw);
8788
+ }));
8789
+ }));
8790
+ for (const registeredProvider of browserProviders) {
8791
+ for (const kw of projectKeywords) {
8792
+ await processKeywordForProvider(registeredProvider, kw);
8793
+ }
8314
8794
  }
8795
+ this.throwIfRunCancelled(runId);
8315
8796
  const allFailed = totalSnapshotsInserted === 0 && providerErrors.size > 0;
8316
8797
  const someFailed = providerErrors.size > 0;
8317
8798
  if (allFailed) {
@@ -8323,18 +8804,16 @@ var JobRunner = class {
8323
8804
  } else {
8324
8805
  this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq16(runs.id, runId)).run();
8325
8806
  }
8807
+ this.flushProviderUsage(projectId, providerDispatchCounts);
8326
8808
  const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
8327
8809
  trackEvent("run.completed", {
8328
8810
  status: finalStatus,
8329
- providerCount: activeProviders.length,
8330
- providers: activeProviders.map((p) => p.adapter.name),
8331
- keywordCount: projectKeywords.length,
8811
+ providerCount: executionContext.providerCount,
8812
+ providers: executionContext.providers,
8813
+ keywordCount: executionContext.keywordCount,
8332
8814
  durationMs: Date.now() - startTime,
8333
- ...runLocation ? { location: runLocation.label } : {}
8815
+ ...executionContext.location ? { location: executionContext.location } : {}
8334
8816
  });
8335
- for (const p of activeProviders) {
8336
- this.incrementUsage(`${projectId}:${p.adapter.name}`, "queries", queriesPerProvider);
8337
- }
8338
8817
  this.incrementUsage(projectId, "runs", 1);
8339
8818
  if (this.onRunCompleted) {
8340
8819
  this.onRunCompleted(runId, projectId).catch((err) => {
@@ -8342,18 +8821,31 @@ var JobRunner = class {
8342
8821
  });
8343
8822
  }
8344
8823
  } catch (err) {
8824
+ const executionContext = {
8825
+ providerCount: activeProviders.length,
8826
+ providers: activeProviders.map((provider) => provider.adapter.name),
8827
+ keywordCount: projectKeywords.length,
8828
+ ...runLocation ? { location: runLocation.label } : {}
8829
+ };
8830
+ if (err instanceof RunCancelledError || this.isRunCancelled(runId)) {
8831
+ this.flushProviderUsage(projectId, providerDispatchCounts);
8832
+ this.handleCancelledRun(runId, projectId, startTime, executionContext);
8833
+ return;
8834
+ }
8345
8835
  const errorMessage = err instanceof Error ? err.message : String(err);
8346
8836
  this.db.update(runs).set({
8347
8837
  status: "failed",
8348
8838
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
8349
8839
  error: errorMessage
8350
8840
  }).where(eq16(runs.id, runId)).run();
8841
+ this.flushProviderUsage(projectId, providerDispatchCounts);
8351
8842
  trackEvent("run.completed", {
8352
8843
  status: "failed",
8353
- providerCount: 0,
8354
- providers: [],
8355
- keywordCount: 0,
8356
- durationMs: Date.now() - startTime
8844
+ providerCount: executionContext.providerCount,
8845
+ providers: executionContext.providers,
8846
+ keywordCount: executionContext.keywordCount,
8847
+ durationMs: Date.now() - startTime,
8848
+ ...executionContext.location ? { location: executionContext.location } : {}
8357
8849
  });
8358
8850
  if (this.onRunCompleted) {
8359
8851
  this.onRunCompleted(runId, projectId).catch((notifErr) => {
@@ -8362,27 +8854,9 @@ var JobRunner = class {
8362
8854
  }
8363
8855
  }
8364
8856
  }
8365
- async waitForRateLimit(window, maxPerMinute) {
8366
- const now = Date.now();
8367
- const windowStart = now - 6e4;
8368
- while (window.length > 0 && window[0] < windowStart) {
8369
- window.shift();
8370
- }
8371
- if (window.length >= maxPerMinute) {
8372
- const oldestInWindow = window[0];
8373
- const waitMs = oldestInWindow + 6e4 - now + 50;
8374
- await new Promise((resolve) => setTimeout(resolve, waitMs));
8375
- const nowAfterWait = Date.now();
8376
- const newWindowStart = nowAfterWait - 6e4;
8377
- while (window.length > 0 && window[0] < newWindowStart) {
8378
- window.shift();
8379
- }
8380
- }
8381
- window.push(Date.now());
8382
- }
8383
8857
  incrementUsage(scope, metric, count) {
8384
8858
  const now = /* @__PURE__ */ new Date();
8385
- const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
8859
+ const period = now.toISOString().slice(0, 10);
8386
8860
  const id = crypto15.randomUUID();
8387
8861
  const existing = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
8388
8862
  if (existing) {
@@ -8398,10 +8872,52 @@ var JobRunner = class {
8398
8872
  }).run();
8399
8873
  }
8400
8874
  }
8875
+ flushProviderUsage(projectId, providerDispatchCounts) {
8876
+ for (const [providerName, count] of providerDispatchCounts.entries()) {
8877
+ if (count <= 0) continue;
8878
+ this.incrementUsage(`${projectId}:${providerName}`, "queries", count);
8879
+ }
8880
+ }
8881
+ getRunState(runId) {
8882
+ return this.db.select({
8883
+ status: runs.status,
8884
+ finishedAt: runs.finishedAt,
8885
+ error: runs.error
8886
+ }).from(runs).where(eq16(runs.id, runId)).get();
8887
+ }
8888
+ isRunCancelled(runId) {
8889
+ return this.getRunState(runId)?.status === "cancelled";
8890
+ }
8891
+ throwIfRunCancelled(runId) {
8892
+ if (this.isRunCancelled(runId)) {
8893
+ throw new RunCancelledError(runId);
8894
+ }
8895
+ }
8896
+ handleCancelledRun(runId, projectId, startTime, context) {
8897
+ const currentRun = this.getRunState(runId);
8898
+ if (currentRun && !currentRun.finishedAt) {
8899
+ this.db.update(runs).set({
8900
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
8901
+ error: currentRun.error ?? "Cancelled by user"
8902
+ }).where(eq16(runs.id, runId)).run();
8903
+ }
8904
+ trackEvent("run.completed", {
8905
+ status: "cancelled",
8906
+ providerCount: context.providerCount,
8907
+ providers: context.providers,
8908
+ keywordCount: context.keywordCount,
8909
+ durationMs: Date.now() - startTime,
8910
+ ...context.location ? { location: context.location } : {}
8911
+ });
8912
+ if (this.onRunCompleted) {
8913
+ this.onRunCompleted(runId, projectId).catch((err) => {
8914
+ console.error("[JobRunner] Notification callback failed:", err);
8915
+ });
8916
+ }
8917
+ }
8401
8918
  };
8402
- function getCurrentPeriod() {
8403
- const d = /* @__PURE__ */ new Date();
8404
- return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
8919
+ function getCurrentUsageDay() {
8920
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
8405
8921
  }
8406
8922
  function domainMatches(domain, canonicalDomain) {
8407
8923
  const normalized = normalizeProjectDomain(canonicalDomain);
@@ -8467,7 +8983,7 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
8467
8983
 
8468
8984
  // src/gsc-sync.ts
8469
8985
  import crypto16 from "crypto";
8470
- import { eq as eq17, and as and6, sql as sql3 } from "drizzle-orm";
8986
+ import { eq as eq17, and as and7, sql as sql3 } from "drizzle-orm";
8471
8987
  function formatDate(d) {
8472
8988
  return d.toISOString().split("T")[0];
8473
8989
  }
@@ -8518,7 +9034,7 @@ async function executeGscSync(db, runId, projectId, opts) {
8518
9034
  });
8519
9035
  console.log(`[GSC Sync] Received ${rows.length} rows`);
8520
9036
  db.delete(gscSearchData).where(
8521
- and6(
9037
+ and7(
8522
9038
  eq17(gscSearchData.projectId, projectId),
8523
9039
  sql3`${gscSearchData.date} >= ${startDate}`,
8524
9040
  sql3`${gscSearchData.date} <= ${endDate}`
@@ -8607,7 +9123,7 @@ async function executeGscSync(db, runId, projectId, opts) {
8607
9123
  }
8608
9124
  }
8609
9125
  const snapshotDate = formatDate(/* @__PURE__ */ new Date());
8610
- db.delete(gscCoverageSnapshots).where(and6(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
9126
+ db.delete(gscCoverageSnapshots).where(and7(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
8611
9127
  db.insert(gscCoverageSnapshots).values({
8612
9128
  id: crypto16.randomUUID(),
8613
9129
  projectId,
@@ -8630,7 +9146,7 @@ async function executeGscSync(db, runId, projectId, opts) {
8630
9146
 
8631
9147
  // src/gsc-inspect-sitemap.ts
8632
9148
  import crypto17 from "crypto";
8633
- import { eq as eq18, and as and7 } from "drizzle-orm";
9149
+ import { eq as eq18, and as and8 } from "drizzle-orm";
8634
9150
 
8635
9151
  // src/sitemap-parser.ts
8636
9152
  var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
@@ -8793,7 +9309,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
8793
9309
  }
8794
9310
  }
8795
9311
  const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
8796
- db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
9312
+ db.delete(gscCoverageSnapshots).where(and8(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
8797
9313
  db.insert(gscCoverageSnapshots).values({
8798
9314
  id: crypto17.randomUUID(),
8799
9315
  projectId,
@@ -8985,7 +9501,7 @@ var Scheduler = class {
8985
9501
  };
8986
9502
 
8987
9503
  // src/notifier.ts
8988
- import { eq as eq20, desc as desc5, and as and8, or as or2 } from "drizzle-orm";
9504
+ import { eq as eq20, desc as desc6, and as and9, or as or2 } from "drizzle-orm";
8989
9505
  import crypto18 from "crypto";
8990
9506
  var Notifier = class {
8991
9507
  db;
@@ -9048,11 +9564,11 @@ var Notifier = class {
9048
9564
  }
9049
9565
  computeTransitions(runId, projectId) {
9050
9566
  const recentRuns = this.db.select().from(runs).where(
9051
- and8(
9567
+ and9(
9052
9568
  eq20(runs.projectId, projectId),
9053
9569
  or2(eq20(runs.status, "completed"), eq20(runs.status, "partial"))
9054
9570
  )
9055
- ).orderBy(desc5(runs.createdAt)).limit(2).all();
9571
+ ).orderBy(desc6(runs.createdAt)).limit(2).all();
9056
9572
  if (recentRuns.length < 2) return [];
9057
9573
  const currentRunId = recentRuns[0].id;
9058
9574
  const previousRunId = recentRuns[1].id;
@@ -9255,6 +9771,20 @@ var DEFAULT_QUOTA = {
9255
9771
  maxRequestsPerMinute: 10,
9256
9772
  maxRequestsPerDay: 1e3
9257
9773
  };
9774
+ var API_ADAPTERS = [
9775
+ geminiAdapter,
9776
+ openaiAdapter,
9777
+ claudeAdapter,
9778
+ localAdapter,
9779
+ perplexityAdapter
9780
+ ];
9781
+ var BROWSER_ADAPTERS = [
9782
+ cdpChatgptAdapter
9783
+ ];
9784
+ var ALL_ADAPTERS = [...API_ADAPTERS, ...BROWSER_ADAPTERS];
9785
+ var adapterMap = Object.fromEntries(
9786
+ API_ADAPTERS.map((a) => [a.name, a])
9787
+ );
9258
9788
  function summarizeProviderConfig(provider, config) {
9259
9789
  return {
9260
9790
  configured: Boolean(config?.apiKey || config?.baseUrl),
@@ -9291,38 +9821,19 @@ async function createServer(opts) {
9291
9821
  const p = providers[k];
9292
9822
  return p?.apiKey || p?.baseUrl;
9293
9823
  }));
9294
- if (providers.gemini?.apiKey) {
9295
- registry.register(geminiAdapter, {
9296
- provider: "gemini",
9297
- apiKey: providers.gemini.apiKey,
9298
- model: providers.gemini.model,
9299
- quotaPolicy: providers.gemini.quota ?? DEFAULT_QUOTA
9300
- });
9301
- }
9302
- if (providers.openai?.apiKey) {
9303
- registry.register(openaiAdapter, {
9304
- provider: "openai",
9305
- apiKey: providers.openai.apiKey,
9306
- model: providers.openai.model,
9307
- quotaPolicy: providers.openai.quota ?? DEFAULT_QUOTA
9308
- });
9309
- }
9310
- if (providers.claude?.apiKey) {
9311
- registry.register(claudeAdapter, {
9312
- provider: "claude",
9313
- apiKey: providers.claude.apiKey,
9314
- model: providers.claude.model,
9315
- quotaPolicy: providers.claude.quota ?? DEFAULT_QUOTA
9316
- });
9317
- }
9318
- if (providers.local?.baseUrl) {
9319
- registry.register(localAdapter, {
9320
- provider: "local",
9321
- apiKey: providers.local.apiKey,
9322
- baseUrl: providers.local.baseUrl,
9323
- model: providers.local.model,
9324
- quotaPolicy: providers.local.quota ?? DEFAULT_QUOTA
9325
- });
9824
+ for (const adapter of API_ADAPTERS) {
9825
+ const entry = providers[adapter.name];
9826
+ if (!entry) continue;
9827
+ const isConfigured = adapter.name === "local" ? !!entry.baseUrl : !!entry.apiKey;
9828
+ if (isConfigured) {
9829
+ registry.register(adapter, {
9830
+ provider: adapter.name,
9831
+ apiKey: entry.apiKey,
9832
+ baseUrl: entry.baseUrl,
9833
+ model: entry.model,
9834
+ quotaPolicy: entry.quota ?? DEFAULT_QUOTA
9835
+ });
9836
+ }
9326
9837
  }
9327
9838
  const cdpConfig = opts.config.cdp;
9328
9839
  if (cdpConfig?.host || cdpConfig?.port) {
@@ -9347,17 +9858,25 @@ async function createServer(opts) {
9347
9858
  });
9348
9859
  }
9349
9860
  });
9350
- const providerSummary = ["gemini", "openai", "claude", "local"].map((name) => ({
9351
- name,
9352
- model: registry.get(name)?.config.model,
9353
- configured: !!registry.get(name),
9354
- quota: registry.get(name)?.config.quotaPolicy
9861
+ const providerSummary = API_ADAPTERS.map((adapter) => ({
9862
+ name: adapter.name,
9863
+ displayName: adapter.displayName,
9864
+ keyUrl: adapter.keyUrl,
9865
+ modelHint: `e.g. ${adapter.modelRegistry.defaultModel}`,
9866
+ model: registry.get(adapter.name)?.config.model,
9867
+ configured: !!registry.get(adapter.name),
9868
+ quota: registry.get(adapter.name)?.config.quotaPolicy
9355
9869
  }));
9356
9870
  const googleSettingsSummary = {
9357
9871
  configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
9358
9872
  };
9359
9873
  const bingSettingsSummary = {
9360
- configured: Boolean(opts.config.bing?.apiKey)
9874
+ // Treat Bing as configured if there is at least one connection with an API key,
9875
+ // OR if a global bing.apiKey is set. The CLI stores keys per-connection
9876
+ // (bing.connections[].apiKey), so checking only bing.apiKey missed existing connections.
9877
+ configured: Boolean(
9878
+ opts.config.bing?.apiKey || opts.config.bing?.connections?.some((c) => c.apiKey)
9879
+ )
9361
9880
  };
9362
9881
  const bingConnectionStore = {
9363
9882
  getConnection: (domain) => {
@@ -9391,7 +9910,6 @@ async function createServer(opts) {
9391
9910
  return true;
9392
9911
  }
9393
9912
  };
9394
- const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
9395
9913
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto19.randomBytes(32).toString("hex");
9396
9914
  const googleConnectionStore = {
9397
9915
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
@@ -9455,6 +9973,13 @@ async function createServer(opts) {
9455
9973
  version: PKG_VERSION
9456
9974
  },
9457
9975
  providerSummary,
9976
+ providerAdapters: [...API_ADAPTERS, ...BROWSER_ADAPTERS].map((a) => ({
9977
+ name: a.name,
9978
+ displayName: a.displayName,
9979
+ mode: a.mode,
9980
+ modelValidationPattern: a.modelRegistry.validationPattern,
9981
+ modelValidationHint: a.modelRegistry.validationHint
9982
+ })),
9458
9983
  googleSettingsSummary,
9459
9984
  bingSettingsSummary,
9460
9985
  bingConnectionStore,
@@ -9465,7 +9990,7 @@ async function createServer(opts) {
9465
9990
  },
9466
9991
  onProviderUpdate: (providerName, apiKey, model, baseUrl, incomingQuota) => {
9467
9992
  const name = providerName;
9468
- if (!(name in adapterMap)) return null;
9993
+ if (!adapterMap[name]) return null;
9469
9994
  if (!opts.config.providers) opts.config.providers = {};
9470
9995
  const existing = opts.config.providers[name];
9471
9996
  const beforeConfig = summarizeProviderConfig(name, existing);
@@ -9748,14 +10273,6 @@ function parseKeywordResponse(raw, count) {
9748
10273
  }
9749
10274
 
9750
10275
  export {
9751
- providerQuotaPolicySchema,
9752
- resolveProviderInput,
9753
- notificationEventSchema,
9754
- getDefaultModel,
9755
- effectiveDomains,
9756
- apiKeys,
9757
- createClient,
9758
- migrate,
9759
10276
  getConfigDir,
9760
10277
  getConfigPath,
9761
10278
  loadConfig,
@@ -9766,6 +10283,13 @@ export {
9766
10283
  isFirstRun,
9767
10284
  showFirstRunNotice,
9768
10285
  trackEvent,
10286
+ providerQuotaPolicySchema,
10287
+ resolveProviderInput,
10288
+ notificationEventSchema,
10289
+ effectiveDomains,
9769
10290
  setGoogleAuthConfig,
10291
+ apiKeys,
10292
+ createClient,
10293
+ migrate,
9770
10294
  createServer
9771
10295
  };