@coldiq/mcp 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ import { providerDisplayName } from './provider-display.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Curated data-quality strengths, keyed by capability → providerId.
4
+ // Each value is a noun phrase that slots into "… for its <strength>". Phrasing
5
+ // is descriptive (deep / strong / broad / specialized), NOT superlative, so it
6
+ // stays defensible on the fallthrough path too.
7
+ // ---------------------------------------------------------------------------
8
+ const STRENGTHS = {
9
+ search_companies: {
10
+ companyenrich: 'broad firmographic coverage with full-text company matching',
11
+ apollo: 'an extensive global company database',
12
+ fullenrich: 'rich firmographic data with strong international coverage',
13
+ pdl: 'a large, structured global company dataset',
14
+ signalbase: 'fast firmographic search across keywords and industries',
15
+ blitzapi: 'LinkedIn-sourced company data',
16
+ limadata: 'LinkedIn-based company discovery',
17
+ predictleads: 'discovery scoped tightly by location and company size',
18
+ theirstack: 'deep technographic and buying-intent coverage',
19
+ sumble: 'technology- and keyword-driven company discovery',
20
+ 'limadata-prospect-filter': 'LinkedIn headcount-bucketed prospecting',
21
+ 'limadata-prospect-url': 'discovery straight from a LinkedIn Sales Navigator search',
22
+ 'linkupapi-search': 'live LinkedIn company search',
23
+ 'linkupapi-fundraising': 'a dedicated index of recently-funded companies',
24
+ 'linkupapi-hiring': 'a dedicated index of actively-hiring companies',
25
+ 'prospeo-search-company': 'keyword, industry and geo company search',
26
+ 'ai-ark-companies': 'multi-filter company discovery',
27
+ },
28
+ find_people: {
29
+ leadsfactory: 'company-scoped contact discovery by persona',
30
+ apollo: 'an extensive global contact database',
31
+ pdl: 'a large, structured global people dataset',
32
+ companyenrich: 'domain-scoped employee lookups',
33
+ 'linkupapi-search-profiles': 'live LinkedIn profile search',
34
+ 'sumble-people-find': 'org-scoped people search by job function',
35
+ 'prospeo-search-person': 'title- and company-based people search',
36
+ 'ai-ark-people': 'multi-filter people discovery',
37
+ 'fullenrich-people-search': 'domain-scoped people search',
38
+ 'findymail-search-employees': 'domain-scoped employee discovery',
39
+ },
40
+ find_email: {
41
+ prospeo: 'strong work-email coverage',
42
+ fullenrich: 'strong international email coverage, especially across Europe',
43
+ findymail: 'high-accuracy verified work emails',
44
+ icypeas: 'broad email-finding coverage',
45
+ 'limadata-work-email': 'name-and-domain work-email lookup',
46
+ blitzapi: 'LinkedIn-URL–based email finding',
47
+ 'limadata-work-email-linkedin': 'LinkedIn-URL–based work-email lookup',
48
+ linkupapi: 'LinkedIn-sourced email finding',
49
+ },
50
+ find_emails: {
51
+ prospeo: 'strong work-email coverage',
52
+ fullenrich: 'strong international email coverage, especially across Europe',
53
+ findymail: 'high-accuracy verified work emails',
54
+ icypeas: 'broad email-finding coverage',
55
+ 'limadata-work-email': 'name-and-domain work-email lookup',
56
+ blitzapi: 'LinkedIn-URL–based email finding',
57
+ 'limadata-work-email-linkedin': 'LinkedIn-URL–based work-email lookup',
58
+ linkupapi: 'LinkedIn-sourced email finding',
59
+ },
60
+ verify_email: {
61
+ findymail: 'reliable deliverability verification',
62
+ icypeas: 'broad email verification',
63
+ instantly: 'deliverability-focused verification',
64
+ 'linkupapi-validate': 'LinkedIn-aware email validation',
65
+ },
66
+ find_phone: {
67
+ findymail: 'verified mobile-number coverage',
68
+ limadata: 'phone lookup by LinkedIn URL or name + company',
69
+ 'ai-ark': 'phone lookup across multiple identifiers',
70
+ },
71
+ enrich_company: {
72
+ companyenrich: 'broad firmographic enrichment from a domain',
73
+ apollo: 'deep firmographic enrichment',
74
+ pdl: 'structured firmographic data',
75
+ findymail: 'domain-based company enrichment',
76
+ wiza: 'company enrichment from name or domain',
77
+ limadata: 'LinkedIn-based company enrichment',
78
+ prospeo: 'company enrichment',
79
+ companyenrich_props: 'company enrichment from name or LinkedIn',
80
+ blitzapi: 'LinkedIn-URL company enrichment',
81
+ icypeas: 'LinkedIn-URL company enrichment',
82
+ builtwith: 'technology-stack detection for a domain',
83
+ openmart: 'local-business firmographics',
84
+ 'linkupapi-by-domain': 'live LinkedIn company data',
85
+ 'linkupapi-by-url': 'live LinkedIn company data',
86
+ },
87
+ enrich_person: {
88
+ 'linkupapi-profile-enrich': 'live LinkedIn profile enrichment',
89
+ 'linkupapi-email-reverse': 'reverse-email lookup from LinkedIn data',
90
+ 'pdl-person-enrich': 'structured person enrichment',
91
+ 'apollo-people-match': 'deep person enrichment',
92
+ 'blitzapi-reverse-email': 'reverse-email profile lookup',
93
+ 'findymail-business-profile': 'LinkedIn profile enrichment',
94
+ 'findymail-reverse-email': 'reverse-email lookup',
95
+ 'icypeas-scrape-profile': 'LinkedIn profile scraping',
96
+ 'icypeas-url-search-profile': 'profile lookup by name and company',
97
+ 'ai-ark-reverse-lookup': 'reverse lookup from email or phone',
98
+ 'icypeas-reverse-email-lookup': 'reverse-email lookup',
99
+ 'pdl-person-identify': 'person identity resolution',
100
+ },
101
+ search_web: {
102
+ serper: 'fast Google search results',
103
+ exa: 'neural, meaning-based web search',
104
+ limadata: 'general web search',
105
+ jina: 'web search and page reading',
106
+ },
107
+ search_jobs: {
108
+ career_site_jobs: 'jobs sourced directly from company career sites',
109
+ linkedin_jobs_api: 'LinkedIn job listings',
110
+ 'theirstack-jobs': 'jobs enriched with company and tech-stack data',
111
+ },
112
+ search_ads: {
113
+ google_ads: 'Google ad-transparency data',
114
+ linkedin_ad_library: 'LinkedIn Ad Library coverage',
115
+ meta_ads: 'Meta (Facebook/Instagram) ad-library coverage',
116
+ twitter_ads: 'X ad coverage',
117
+ reddit_ads: 'Reddit ad coverage',
118
+ },
119
+ search_places: {
120
+ openmart: 'rich local-business data across the US, CA, AU, PR and NZ',
121
+ google_maps: 'broad global places coverage from Google Maps',
122
+ },
123
+ get_place_reviews: {
124
+ google_maps_reviews: 'Google Maps review data',
125
+ },
126
+ find_influencers: {
127
+ influencers_similar: 'lookalike creator discovery from a seed handle',
128
+ influencers_discovery: 'creator discovery by topic and audience',
129
+ },
130
+ search_reddit: {
131
+ reddit: 'Reddit post and comment search',
132
+ },
133
+ find_signals: {
134
+ 'signalbase-funding': 'real-time funding-round signals',
135
+ 'signalbase-acquisition': 'acquisition signals',
136
+ 'signalbase-hiring': 'hiring signals',
137
+ 'signalbase-job-change': 'job-change signals',
138
+ 'theirstack-hiring': 'hiring signals from job-posting data',
139
+ 'theirstack-intent-discovery': 'buying-intent discovery',
140
+ 'theirstack-buying-intents': 'buying-intent signals from tech and job data',
141
+ 'predictleads-financing': 'financing-event signals',
142
+ 'predictleads-news': 'company-news signals',
143
+ 'predictleads-startup-posts': 'startup-announcement signals',
144
+ },
145
+ fetch_page_content: {
146
+ 'exa-contents': 'clean page-content extraction',
147
+ },
148
+ };
149
+ // ---------------------------------------------------------------------------
150
+ // Per-capability noun used in the plain fallback line ("X matched this <noun>").
151
+ // ---------------------------------------------------------------------------
152
+ const CAPABILITY_NOUN = {
153
+ search_companies: 'company search',
154
+ find_people: 'people search',
155
+ find_email: 'email lookup',
156
+ find_emails: 'email lookup',
157
+ verify_email: 'email verification',
158
+ find_phone: 'phone lookup',
159
+ enrich_company: 'company enrichment',
160
+ enrich_person: 'person enrichment',
161
+ search_web: 'web search',
162
+ search_jobs: 'job search',
163
+ search_ads: 'ad search',
164
+ search_places: 'places search',
165
+ get_place_reviews: 'reviews lookup',
166
+ find_influencers: 'influencer search',
167
+ search_reddit: 'Reddit search',
168
+ search_seo: 'SEO lookup',
169
+ find_signals: 'signal search',
170
+ fetch_page_content: 'page fetch',
171
+ };
172
+ // ---------------------------------------------------------------------------
173
+ // Signal detection — which input dimensions drove routing, in human terms.
174
+ // Ordered most-distinctive-first so signals[0] is the routing-relevant one.
175
+ // ---------------------------------------------------------------------------
176
+ function arr(v) {
177
+ return Array.isArray(v) && v.length > 0;
178
+ }
179
+ function num(v) {
180
+ return typeof v === 'number';
181
+ }
182
+ function str(v) {
183
+ return typeof v === 'string' && v.length > 0;
184
+ }
185
+ function detectSignals(capability, input) {
186
+ const out = [];
187
+ const push = (cond, label) => {
188
+ if (cond && !out.includes(label))
189
+ out.push(label);
190
+ };
191
+ switch (capability) {
192
+ case 'search_companies':
193
+ push(arr(input.technologies), 'tech-stack filter');
194
+ push(arr(input.funding_stages) ||
195
+ num(input.min_funding_amount) || num(input.max_funding_amount) ||
196
+ num(input.min_funding_year) || num(input.max_funding_year), 'funding filter');
197
+ push(num(input.min_revenue) || num(input.max_revenue), 'revenue filter');
198
+ push(arr(input.exclude_domains) || arr(input.exclude_industries) || arr(input.exclude_countries), 'exclusion list');
199
+ push(num(input.min_workforce_growth_pct), 'workforce-growth filter');
200
+ push(input.is_hiring === true, 'hiring signal');
201
+ push(num(input.min_founded_year) || num(input.max_founded_year), 'founding-year filter');
202
+ push(num(input.min_employees) || num(input.max_employees), 'company-size filter');
203
+ push(str(input.linkedin_search_url), 'LinkedIn Sales Navigator URL');
204
+ push(arr(input.industries), 'industry filter');
205
+ push(arr(input.keywords), 'keyword search');
206
+ push(arr(input.countries) || arr(input.locations), 'geo filter');
207
+ break;
208
+ case 'find_people':
209
+ push(arr(input.company_domains), 'company-domain filter');
210
+ push(arr(input.company_linkedin_urls), 'company LinkedIn filter');
211
+ push(arr(input.job_titles), 'job-title filter');
212
+ push(arr(input.seniorities), 'seniority filter');
213
+ push(arr(input.keywords), 'keyword search');
214
+ push(arr(input.locations), 'geo filter');
215
+ break;
216
+ case 'find_email':
217
+ push(str(input.linkedin_url), 'LinkedIn URL');
218
+ push(str(input.domain) || str(input.company_name), 'name + company');
219
+ break;
220
+ case 'find_emails': {
221
+ const people = input.people ?? [];
222
+ push(people.some((p) => str(p.linkedin_url)), 'LinkedIn URLs');
223
+ push(people.some((p) => str(p.domain)), 'name + domain');
224
+ break;
225
+ }
226
+ case 'enrich_company':
227
+ push(str(input.domain), 'company domain');
228
+ push(str(input.linkedin_url), 'LinkedIn URL');
229
+ push(str(input.name), 'company name');
230
+ break;
231
+ case 'enrich_person':
232
+ push(str(input.email), 'email');
233
+ push(str(input.linkedin_url), 'LinkedIn URL');
234
+ push(str(input.first_name) && str(input.last_name), 'name + company');
235
+ break;
236
+ case 'find_signals':
237
+ if (str(input.signal_type))
238
+ out.push(`${String(input.signal_type)} signals`);
239
+ push(arr(input.companies) || arr(input.domains), 'company filter');
240
+ break;
241
+ case 'search_jobs':
242
+ push(arr(input.technologies), 'tech-stack filter');
243
+ push(arr(input.company_domains), 'company-domain filter');
244
+ push(arr(input.job_titles) || arr(input.keywords), 'role filter');
245
+ push(arr(input.locations), 'geo filter');
246
+ break;
247
+ case 'search_ads':
248
+ push(arr(input.search_urls), 'LinkedIn Ad Library URL');
249
+ push(arr(input.domains) || arr(input.advertiser_ids), 'advertiser filter');
250
+ push(str(input.query), 'keyword search');
251
+ break;
252
+ default:
253
+ // Other capabilities: no curated signal vocabulary — routing is single-source
254
+ // or non-discriminating, so leave signals empty and rely on the base strength.
255
+ break;
256
+ }
257
+ return out;
258
+ }
259
+ // ---------------------------------------------------------------------------
260
+ // Builder
261
+ // ---------------------------------------------------------------------------
262
+ /**
263
+ * Build a data-quality-framed reason for why `providerId` was selected for
264
+ * `capability` given `input`. Returns undefined only when there is genuinely
265
+ * nothing safe to say.
266
+ */
267
+ export function buildSelectionInsight(capability, providerId, input, ctx) {
268
+ const name = providerDisplayName(providerId);
269
+ const signals = detectSignals(capability, input);
270
+ // User pinned this provider — it wasn't our choice, so don't claim it was.
271
+ if (ctx.pinnedByUser) {
272
+ return { insight: `Used ${name} as requested.`, signals };
273
+ }
274
+ const strength = STRENGTHS[capability]?.[providerId];
275
+ const noun = CAPABILITY_NOUN[capability] ?? 'request';
276
+ const primary = signals[0];
277
+ // No curated edge for this provider — emit a plain, true line, never invent one.
278
+ if (!strength) {
279
+ const insight = primary
280
+ ? `${name} matched your ${primary} for this ${noun}.`
281
+ : `${name} matched this ${noun}.`;
282
+ return { insight, signals };
283
+ }
284
+ // Fallthrough: higher-ranked providers returned nothing. Soften — state the
285
+ // coverage fact without implying we judged it the single best fit upfront.
286
+ if (ctx.wasFallback) {
287
+ return { insight: `${name} returned the match here, with ${strength}.`, signals };
288
+ }
289
+ // Capability routing: chosen because it is best-suited to these inputs.
290
+ const insight = primary
291
+ ? `Routed to ${name} for its ${strength}, matched to your ${primary}.`
292
+ : `Routed to ${name} for its ${strength}.`;
293
+ return { insight, signals };
294
+ }
295
+ //# sourceMappingURL=selection-insight.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selection-insight.js","sourceRoot":"","sources":["../../src/utils/selection-insight.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldiq/mcp",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/executor.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import { callApi } from './client.js'
2
2
  import { getProviders, getSearchWebProviders } from './registry.js'
3
3
  import type { Capability, ProviderEntry } from './registry.js'
4
+ import { providerDisplayName } from './utils/provider-display.js'
5
+ import { buildSelectionInsight } from './utils/selection-insight.js'
4
6
 
5
7
  export interface ExecutionResult {
6
8
  data: unknown
7
9
  _meta: {
8
10
  provider: string
11
+ /** Clean branded display name for `provider` (never a raw slug or "Apify"). */
12
+ provider_name?: string
13
+ /** Data-quality-framed reason this provider was selected, for the chat surface. */
14
+ selection_insight?: string
15
+ /** Input dimensions that drove routing (e.g. ["tech-stack filter"]), for UI chips. */
16
+ selection_signals?: string[]
9
17
  latencyMs: number
10
18
  matchedFrom?: Record<string, string>
11
19
  /** Net ColdIQ credits charged for this call (parsed from `X-ColdIQ-Credits-Charged`). */
@@ -306,7 +314,21 @@ export async function executeWithFallback(
306
314
 
307
315
  if (hasResult) {
308
316
  log(`${capability} → ${provider.id} ✓ (${result.latencyMs}ms)`)
309
- const meta: ExecutionResult['_meta'] = { provider: provider.id, latencyMs: result.latencyMs }
317
+ const meta: ExecutionResult['_meta'] = {
318
+ provider: provider.id,
319
+ provider_name: providerDisplayName(provider.id),
320
+ latencyMs: result.latencyMs,
321
+ }
322
+ // `errors.length > 0` here means higher-ranked applicable providers returned
323
+ // nothing and we fell through — soften the insight wording accordingly.
324
+ const insight = buildSelectionInsight(capability, provider.id, input, {
325
+ wasFallback: errors.length > 0,
326
+ pinnedByUser: isConstrained,
327
+ })
328
+ if (insight) {
329
+ meta.selection_insight = insight.insight
330
+ if (insight.signals.length > 0) meta.selection_signals = insight.signals
331
+ }
310
332
  if (options?.matchedFrom && Object.keys(options.matchedFrom).length > 0) {
311
333
  meta.matchedFrom = options.matchedFrom
312
334
  }
@@ -2,6 +2,8 @@ import { z } from 'zod'
2
2
  import { callApi, type ApiResponse } from '../client.js'
3
3
  import { executeWithFallback, isExecutionError } from '../executor.js'
4
4
  import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
5
+ import { providerDisplayName } from '../utils/provider-display.js'
6
+ import { buildSelectionInsight } from '../utils/selection-insight.js'
5
7
 
6
8
  // Single-find_email registry providers that the bulk pipeline does NOT already cover.
7
9
  // Used as a fallback waterfall for residual misses after Prospeo + FullEnrich + Findymail + Icypeas.
@@ -462,6 +464,46 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
462
464
  meta.matchedFrom = resolved.matchedFrom
463
465
  }
464
466
 
467
+ // Batch-level selection insight: the per-person waterfall returns a provider per
468
+ // result, so attribute the batch to the provider that resolved the most emails.
469
+ // Prospeo is the primary bulk step (Step 1); anything else means we relied on a
470
+ // fallback step, so soften the wording.
471
+ if (found > 0) {
472
+ const winCounts = new Map<string, number>()
473
+ for (const r of results) {
474
+ if (r.email && r.provider) winCounts.set(r.provider, (winCounts.get(r.provider) ?? 0) + 1)
475
+ }
476
+ // Tie-break deterministically by waterfall rank: scan in FIND_EMAILS_PROVIDERS
477
+ // order and replace only on a strictly greater count, so an equal-count primary
478
+ // (Prospeo, Step 1) is never displaced by a later fallback step — keeps the
479
+ // attribution stable regardless of people-input order.
480
+ let dominant: string | undefined
481
+ let best = 0
482
+ for (const id of FIND_EMAILS_PROVIDERS) {
483
+ const count = winCounts.get(id) ?? 0
484
+ if (count > best) { best = count; dominant = id }
485
+ }
486
+ if (dominant) {
487
+ const insight = buildSelectionInsight('find_emails', dominant, restInput, {
488
+ // Steps 2-4 only run on Prospeo's misses, so a non-Prospeo winner is a
489
+ // genuine fallthrough — unless the caller pinned providers (no Prospeo step),
490
+ // in which case the pinnedByUser branch governs the wording anyway.
491
+ wasFallback: dominant !== 'prospeo' && !isConstrained,
492
+ pinnedByUser: isConstrained,
493
+ })
494
+ if (insight) {
495
+ meta.selection_insight = insight.insight
496
+ if (insight.signals.length > 0) meta.selection_signals = insight.signals
497
+ }
498
+ }
499
+ }
500
+
501
+ // Surface branded provider names per person so users never see a raw slug.
502
+ const brandedResults = results.map((r) => ({
503
+ ...r,
504
+ provider_name: r.provider ? providerDisplayName(r.provider) : null,
505
+ }))
506
+
465
507
  // Surface the silent-failure case the user reported: 200 OK with all-nulls is misleading
466
508
  // when the nulls were caused by provider failures rather than legitimate no-coverage.
467
509
  const isError = batchStatus === 'failed'
@@ -470,7 +512,7 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
470
512
  content: [
471
513
  {
472
514
  type: 'text' as const,
473
- text: JSON.stringify({ data: { results, found, total: people.length }, _meta: meta }),
515
+ text: JSON.stringify({ data: { results: brandedResults, found, total: people.length }, _meta: meta }),
474
516
  },
475
517
  ],
476
518
  ...(isError ? { isError: true } : {}),
@@ -0,0 +1,150 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Provider display names
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Internal provider IDs (registry.ts + the find_emails waterfall) are a mix of
6
+ // clean vendor names ("apollo"), already-branded slugs ("reddit_ads"), and ugly
7
+ // internal slugs ("limadata-work-email"). User-facing surfaces — the selection
8
+ // insight and `_meta.provider_name` — must show a clean branded label instead.
9
+ //
10
+ // Naming the real data product (FullEnrich, TheirStack, …) is intentional: it IS
11
+ // the ColdIQ "we picked the best tool" intelligence on display. The ONLY thing
12
+ // that must never surface is the Apify backend — and since no ID contains the
13
+ // word "Apify", that constraint is satisfied by mapping the Apify-backed scrapers
14
+ // to their branded data-source name (e.g. "Reddit Ads").
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const DISPLAY_NAMES: Record<string, string> = {
18
+ // --- B2B data vendors (shared across capabilities) -----------------------
19
+ companyenrich: 'CompanyEnrich',
20
+ companyenrich_props: 'CompanyEnrich',
21
+ apollo: 'Apollo',
22
+ 'apollo-people-match': 'Apollo',
23
+ fullenrich: 'FullEnrich',
24
+ 'fullenrich-people-search': 'FullEnrich',
25
+ pdl: 'People Data Labs',
26
+ 'pdl-person-enrich': 'People Data Labs',
27
+ 'pdl-person-identify': 'People Data Labs',
28
+ signalbase: 'SignalBase',
29
+ 'signalbase-funding': 'SignalBase',
30
+ 'signalbase-acquisition': 'SignalBase',
31
+ 'signalbase-hiring': 'SignalBase',
32
+ 'signalbase-job-change': 'SignalBase',
33
+ blitzapi: 'BlitzAPI',
34
+ 'blitzapi-reverse-email': 'BlitzAPI',
35
+ limadata: 'LimaData',
36
+ 'limadata-prospect-filter': 'LimaData',
37
+ 'limadata-prospect-url': 'LimaData',
38
+ 'limadata-work-email': 'LimaData',
39
+ 'limadata-work-email-linkedin': 'LimaData',
40
+ predictleads: 'PredictLeads',
41
+ 'predictleads-financing': 'PredictLeads',
42
+ 'predictleads-news': 'PredictLeads',
43
+ 'predictleads-startup-posts': 'PredictLeads',
44
+ theirstack: 'TheirStack',
45
+ 'theirstack-jobs': 'TheirStack',
46
+ 'theirstack-hiring': 'TheirStack',
47
+ 'theirstack-intent-discovery': 'TheirStack',
48
+ 'theirstack-buying-intents': 'TheirStack',
49
+ sumble: 'Sumble',
50
+ 'sumble-people-find': 'Sumble',
51
+ prospeo: 'Prospeo',
52
+ 'prospeo-search-company': 'Prospeo',
53
+ 'prospeo-search-person': 'Prospeo',
54
+ 'ai-ark': 'AI-ARK',
55
+ 'ai-ark-companies': 'AI-ARK',
56
+ 'ai-ark-people': 'AI-ARK',
57
+ 'ai-ark-reverse-lookup': 'AI-ARK',
58
+ leadsfactory: 'LeadsFactory',
59
+ findymail: 'Findymail',
60
+ 'findymail-search-employees': 'Findymail',
61
+ 'findymail-business-profile': 'Findymail',
62
+ 'findymail-reverse-email': 'Findymail',
63
+ icypeas: 'Icypeas',
64
+ 'icypeas-scrape-profile': 'Icypeas',
65
+ 'icypeas-url-search-profile': 'Icypeas',
66
+ 'icypeas-reverse-email-lookup': 'Icypeas',
67
+ wiza: 'Wiza',
68
+ builtwith: 'BuiltWith',
69
+ openmart: 'Openmart',
70
+ instantly: 'Instantly',
71
+
72
+ // --- LinkupAPI (LinkedIn data) -------------------------------------------
73
+ linkupapi: 'LinkupAPI',
74
+ 'linkupapi-search': 'LinkupAPI',
75
+ 'linkupapi-fundraising': 'LinkupAPI',
76
+ 'linkupapi-hiring': 'LinkupAPI',
77
+ 'linkupapi-search-profiles': 'LinkupAPI',
78
+ 'linkupapi-by-domain': 'LinkupAPI',
79
+ 'linkupapi-by-url': 'LinkupAPI',
80
+ 'linkupapi-profile-enrich': 'LinkupAPI',
81
+ 'linkupapi-email-reverse': 'LinkupAPI',
82
+ 'linkupapi-validate': 'LinkupAPI',
83
+
84
+ // --- search_web ----------------------------------------------------------
85
+ serper: 'Serper',
86
+ exa: 'Exa',
87
+ 'exa-contents': 'Exa',
88
+ jina: 'Jina',
89
+
90
+ // --- search_jobs ---------------------------------------------------------
91
+ career_site_jobs: 'Career Site Jobs',
92
+ linkedin_jobs_api: 'LinkedIn Jobs',
93
+
94
+ // --- search_ads ----------------------------------------------------------
95
+ google_ads: 'Google Ads',
96
+ linkedin_ad_library: 'LinkedIn Ad Library',
97
+ meta_ads: 'Meta Ads',
98
+ twitter_ads: 'X Ads',
99
+ reddit_ads: 'Reddit Ads',
100
+
101
+ // --- search_places / reviews ---------------------------------------------
102
+ google_maps: 'Google Maps',
103
+ google_maps_reviews: 'Google Maps',
104
+
105
+ // --- find_influencers ----------------------------------------------------
106
+ influencers_similar: 'Influencer Discovery',
107
+ influencers_discovery: 'Influencer Discovery',
108
+
109
+ // --- search_reddit -------------------------------------------------------
110
+ reddit: 'Reddit',
111
+
112
+ // --- search_seo (functional sub-actions) ---------------------------------
113
+ kw_search_volume: 'Keyword Search Volume',
114
+ kw_trends: 'Keyword Trends',
115
+ serp_google: 'Google SERP',
116
+ serp_bing: 'Bing SERP',
117
+ serp_youtube: 'YouTube SERP',
118
+ bl_summary: 'Backlink Summary',
119
+ bl_backlinks: 'Backlinks',
120
+ bl_referring: 'Referring Domains',
121
+ domain_tech: 'Domain Technologies',
122
+ domain_whois: 'Domain WHOIS',
123
+ labs_rank_overview: 'Rank Overview',
124
+ labs_ranked_kw: 'Ranked Keywords',
125
+ labs_competitors: 'Competitor Domains',
126
+ labs_kw_ideas: 'Keyword Ideas',
127
+ page_lighthouse: 'Lighthouse Audit',
128
+ page_content: 'Page Content',
129
+ }
130
+
131
+ /**
132
+ * Title-case an unmapped provider ID as a defensive fallback so a newly added
133
+ * provider still renders acceptably (and never leaks a raw slug). E.g.
134
+ * "some-new-vendor" → "Some New Vendor".
135
+ */
136
+ function titleCaseId(id: string): string {
137
+ return id
138
+ .split(/[-_]/)
139
+ .filter(Boolean)
140
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
141
+ .join(' ')
142
+ }
143
+
144
+ /**
145
+ * Map an internal provider ID to a clean, branded display name. Falls back to a
146
+ * title-cased form of the ID when unmapped. Never returns "Apify".
147
+ */
148
+ export function providerDisplayName(id: string): string {
149
+ return DISPLAY_NAMES[id] ?? titleCaseId(id)
150
+ }