@coldiq/mcp 0.1.8 → 0.1.10

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.
Files changed (114) hide show
  1. package/README.md +1 -1
  2. package/dist/executor.d.ts +8 -1
  3. package/dist/executor.d.ts.map +1 -1
  4. package/dist/executor.js +45 -18
  5. package/dist/executor.js.map +1 -1
  6. package/dist/registry.d.ts +9 -0
  7. package/dist/registry.d.ts.map +1 -1
  8. package/dist/registry.js +16 -0
  9. package/dist/registry.js.map +1 -1
  10. package/dist/tools/enrich-company.d.ts +2 -1
  11. package/dist/tools/enrich-company.d.ts.map +1 -1
  12. package/dist/tools/enrich-company.js +10 -3
  13. package/dist/tools/enrich-company.js.map +1 -1
  14. package/dist/tools/enrich-person.d.ts +1 -0
  15. package/dist/tools/enrich-person.d.ts.map +1 -1
  16. package/dist/tools/enrich-person.js +10 -2
  17. package/dist/tools/enrich-person.js.map +1 -1
  18. package/dist/tools/fetch-page-content.d.ts +1 -0
  19. package/dist/tools/fetch-page-content.d.ts.map +1 -1
  20. package/dist/tools/fetch-page-content.js +8 -1
  21. package/dist/tools/fetch-page-content.js.map +1 -1
  22. package/dist/tools/find-email.d.ts +1 -0
  23. package/dist/tools/find-email.d.ts.map +1 -1
  24. package/dist/tools/find-email.js +9 -2
  25. package/dist/tools/find-email.js.map +1 -1
  26. package/dist/tools/find-emails.d.ts +8 -0
  27. package/dist/tools/find-emails.d.ts.map +1 -1
  28. package/dist/tools/find-emails.js +64 -33
  29. package/dist/tools/find-emails.js.map +1 -1
  30. package/dist/tools/find-influencers.d.ts +1 -0
  31. package/dist/tools/find-influencers.d.ts.map +1 -1
  32. package/dist/tools/find-influencers.js +8 -1
  33. package/dist/tools/find-influencers.js.map +1 -1
  34. package/dist/tools/find-people.d.ts +1 -0
  35. package/dist/tools/find-people.d.ts.map +1 -1
  36. package/dist/tools/find-people.js +12 -5
  37. package/dist/tools/find-people.js.map +1 -1
  38. package/dist/tools/find-phone.d.ts +1 -0
  39. package/dist/tools/find-phone.d.ts.map +1 -1
  40. package/dist/tools/find-phone.js +9 -2
  41. package/dist/tools/find-phone.js.map +1 -1
  42. package/dist/tools/find-signals.d.ts +1 -0
  43. package/dist/tools/find-signals.d.ts.map +1 -1
  44. package/dist/tools/find-signals.js +14 -7
  45. package/dist/tools/find-signals.js.map +1 -1
  46. package/dist/tools/search-ads.d.ts +1 -0
  47. package/dist/tools/search-ads.d.ts.map +1 -1
  48. package/dist/tools/search-ads.js +8 -1
  49. package/dist/tools/search-ads.js.map +1 -1
  50. package/dist/tools/search-companies.d.ts +2 -1
  51. package/dist/tools/search-companies.d.ts.map +1 -1
  52. package/dist/tools/search-companies.js +9 -2
  53. package/dist/tools/search-companies.js.map +1 -1
  54. package/dist/tools/search-jobs.d.ts +1 -0
  55. package/dist/tools/search-jobs.d.ts.map +1 -1
  56. package/dist/tools/search-jobs.js +9 -2
  57. package/dist/tools/search-jobs.js.map +1 -1
  58. package/dist/tools/search-places.d.ts +1 -0
  59. package/dist/tools/search-places.d.ts.map +1 -1
  60. package/dist/tools/search-places.js +8 -1
  61. package/dist/tools/search-places.js.map +1 -1
  62. package/dist/tools/search-reddit.d.ts +1 -0
  63. package/dist/tools/search-reddit.d.ts.map +1 -1
  64. package/dist/tools/search-reddit.js +8 -1
  65. package/dist/tools/search-reddit.js.map +1 -1
  66. package/dist/tools/search-seo.d.ts +1 -0
  67. package/dist/tools/search-seo.d.ts.map +1 -1
  68. package/dist/tools/search-seo.js +8 -1
  69. package/dist/tools/search-seo.js.map +1 -1
  70. package/dist/tools/search-web.d.ts +1 -0
  71. package/dist/tools/search-web.d.ts.map +1 -1
  72. package/dist/tools/search-web.js +10 -2
  73. package/dist/tools/search-web.js.map +1 -1
  74. package/dist/tools/verify-email.d.ts +2 -1
  75. package/dist/tools/verify-email.d.ts.map +1 -1
  76. package/dist/tools/verify-email.js +9 -2
  77. package/dist/tools/verify-email.js.map +1 -1
  78. package/dist/utils/fuzzy.d.ts +13 -0
  79. package/dist/utils/fuzzy.d.ts.map +1 -0
  80. package/dist/utils/fuzzy.js +46 -0
  81. package/dist/utils/fuzzy.js.map +1 -0
  82. package/dist/utils/provider-resolver.d.ts +34 -0
  83. package/dist/utils/provider-resolver.d.ts.map +1 -0
  84. package/dist/utils/provider-resolver.js +235 -0
  85. package/dist/utils/provider-resolver.js.map +1 -0
  86. package/package.json +1 -1
  87. package/src/executor.ts +55 -14
  88. package/src/registry.ts +20 -0
  89. package/src/tools/enrich-company.ts +10 -3
  90. package/src/tools/enrich-person.ts +10 -2
  91. package/src/tools/fetch-page-content.ts +8 -1
  92. package/src/tools/find-email.ts +9 -2
  93. package/src/tools/find-emails.ts +78 -41
  94. package/src/tools/find-influencers.ts +8 -1
  95. package/src/tools/find-people.ts +12 -5
  96. package/src/tools/find-phone.ts +9 -2
  97. package/src/tools/find-signals.ts +15 -7
  98. package/src/tools/search-ads.ts +8 -1
  99. package/src/tools/search-companies.ts +9 -2
  100. package/src/tools/search-jobs.ts +9 -2
  101. package/src/tools/search-places.ts +8 -1
  102. package/src/tools/search-reddit.ts +8 -1
  103. package/src/tools/search-seo.ts +8 -1
  104. package/src/tools/search-web.ts +10 -2
  105. package/src/tools/verify-email.ts +9 -2
  106. package/src/utils/fuzzy.ts +57 -0
  107. package/src/utils/provider-resolver.ts +306 -0
  108. package/tests/executor.test.ts +184 -4
  109. package/tests/registry.test.ts +22 -1
  110. package/tests/tools/find-email.test.ts +43 -0
  111. package/tests/tools/find-emails.test.ts +87 -0
  112. package/tests/tools/search-companies.test.ts +40 -0
  113. package/tests/utils/fuzzy.test.ts +63 -0
  114. package/tests/utils/provider-resolver.test.ts +145 -0
@@ -0,0 +1,57 @@
1
+ export function normalize(s: string): string {
2
+ return s.toLowerCase().replace(/[^a-z0-9]/g, '')
3
+ }
4
+
5
+ function levenshtein(a: string, b: string): number {
6
+ const m = a.length
7
+ const n = b.length
8
+ const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
9
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
10
+ )
11
+ for (let i = 1; i <= m; i++) {
12
+ for (let j = 1; j <= n; j++) {
13
+ dp[i][j] = a[i - 1] === b[j - 1]
14
+ ? dp[i - 1][j - 1]
15
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
16
+ }
17
+ }
18
+ return dp[m][n]
19
+ }
20
+
21
+ export type MatchVia = 'exact' | 'normalized' | 'fuzzy'
22
+
23
+ export interface FuzzyMatchResult {
24
+ matched: string
25
+ via: MatchVia
26
+ }
27
+
28
+ /**
29
+ * Match a user-supplied string against a list of candidate IDs.
30
+ * Precedence: exact → normalized (case + punctuation stripped) → Levenshtein ≤ threshold.
31
+ * Threshold = min(2, floor(len/3)) where len = normalized candidate length.
32
+ */
33
+ export function fuzzyMatch(input: string, candidates: string[]): FuzzyMatchResult | null {
34
+ // Exact
35
+ if (candidates.includes(input)) return { matched: input, via: 'exact' }
36
+
37
+ const normInput = normalize(input)
38
+
39
+ // Normalized exact
40
+ for (const c of candidates) {
41
+ if (normalize(c) === normInput) return { matched: c, via: 'normalized' }
42
+ }
43
+
44
+ // Levenshtein
45
+ let best: { matched: string; dist: number } | null = null
46
+ for (const c of candidates) {
47
+ const normC = normalize(c)
48
+ const threshold = Math.min(2, Math.floor(normC.length / 3))
49
+ if (threshold === 0) continue
50
+ const dist = levenshtein(normInput, normC)
51
+ if (dist <= threshold && (best === null || dist < best.dist)) {
52
+ best = { matched: c, dist }
53
+ }
54
+ }
55
+
56
+ return best ? { matched: best.matched, via: 'fuzzy' } : null
57
+ }
@@ -0,0 +1,306 @@
1
+ import { listCapabilityProviderIds, getProviderApplicabilityGuard } from '../registry.js'
2
+ import type { Capability } from '../registry.js'
3
+ import { fuzzyMatch } from './fuzzy.js'
4
+
5
+ // find_emails uses a custom waterfall — its providers are not in the registry.
6
+ // Exported so find-emails.ts stays in sync without a second hardcoded list.
7
+ export const FIND_EMAILS_PROVIDERS = ['prospeo', 'fullenrich', 'findymail', 'icypeas']
8
+
9
+ export function getProvidersForCapability(capability: Capability | 'find_emails'): string[] {
10
+ if (capability === 'find_emails') return FIND_EMAILS_PROVIDERS
11
+ return listCapabilityProviderIds(capability as Capability)
12
+ }
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // User-facing error payloads
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface ToolErrorPayload {
19
+ error: string
20
+ available_providers: string[]
21
+ }
22
+
23
+ const TRUST_LINE =
24
+ 'Or just run the tool without specifying providers — ColdIQ will automatically pick the best tool for your needs.'
25
+
26
+ export function buildUnrecognizedError(
27
+ input: string,
28
+ capability: Capability | 'find_emails',
29
+ available: string[],
30
+ ): ToolErrorPayload {
31
+ return {
32
+ error: `'${input}' is not a recognized provider for this tool. Available providers: ${available.join(', ')}. ${TRUST_LINE}`,
33
+ available_providers: available,
34
+ }
35
+ }
36
+
37
+ export function buildGatedError(
38
+ provider: string,
39
+ gate: GateDesc,
40
+ capability: Capability | 'find_emails',
41
+ available: string[],
42
+ input: Record<string, unknown>,
43
+ ): ToolErrorPayload {
44
+ const others = available.filter((p) => {
45
+ if (p === provider) return false
46
+ const guard = getProviderApplicabilityGuard(capability as Capability, p)
47
+ try {
48
+ return !guard || guard(input)
49
+ } catch {
50
+ return false
51
+ }
52
+ })
53
+ const othersText = others.length > 0
54
+ ? `Other providers that work with your inputs: ${others.join(', ')}. `
55
+ : ''
56
+ const clause = gate.kind === 'incompatible_with'
57
+ ? `is not compatible with ${gate.fields}`
58
+ : `requires ${gate.fields}, which wasn't provided`
59
+ return {
60
+ error: `'${provider}' ${clause}. ${othersText}${TRUST_LINE}`,
61
+ available_providers: others,
62
+ }
63
+ }
64
+
65
+ export function buildAllFailedError(
66
+ tried: string[],
67
+ capability: Capability | 'find_emails',
68
+ available: string[],
69
+ ): ToolErrorPayload {
70
+ const remaining = available.filter((p) => !tried.includes(p))
71
+ const remainingText = remaining.length > 0
72
+ ? `You can retry with one of these instead: ${remaining.join(', ')}. `
73
+ : ''
74
+ return {
75
+ error: `Couldn't fulfill your request with the providers you specified (${tried.join(', ')}). ${remainingText}${TRUST_LINE}`,
76
+ available_providers: remaining,
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Resolver
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export interface ResolveOk {
85
+ ok: true
86
+ providers: string[]
87
+ matchedFrom?: Record<string, string>
88
+ }
89
+
90
+ export interface ResolveError {
91
+ ok: false
92
+ error: ToolErrorPayload
93
+ }
94
+
95
+ export type ResolveResult = ResolveOk | ResolveError
96
+
97
+ /**
98
+ * Resolve `use_providers` field against the capability's known provider IDs.
99
+ * - Empty/absent → auto-route (ok: true, providers: [])
100
+ * - Unknown name → unrecognized error
101
+ * - isApplicable fails → gated error
102
+ * - Otherwise → resolved IDs in user order
103
+ */
104
+ export function resolvePreferredProviders(
105
+ capability: Capability | 'find_emails',
106
+ input: Record<string, unknown>,
107
+ useProviders: unknown,
108
+ ): ResolveResult {
109
+ if (!Array.isArray(useProviders) || useProviders.length === 0) {
110
+ return { ok: true, providers: [] }
111
+ }
112
+
113
+ const available = getProvidersForCapability(capability)
114
+ const resolved: string[] = []
115
+ const matchedFrom: Record<string, string> = {}
116
+
117
+ for (const raw of useProviders) {
118
+ const userInput = typeof raw === 'string' ? raw.trim() : String(raw)
119
+ const match = fuzzyMatch(userInput, available)
120
+ if (!match) {
121
+ return { ok: false, error: buildUnrecognizedError(userInput, capability, available) }
122
+ }
123
+ if (match.via !== 'exact') {
124
+ matchedFrom[userInput] = match.matched
125
+ }
126
+ if (!resolved.includes(match.matched)) {
127
+ resolved.push(match.matched)
128
+ }
129
+ }
130
+
131
+ // Check isApplicable for each resolved provider (registry-based capabilities only)
132
+ if (capability !== 'find_emails') {
133
+ for (const id of resolved) {
134
+ const guard = getProviderApplicabilityGuard(capability as Capability, id)
135
+ if (guard) {
136
+ let applicable: boolean
137
+ try {
138
+ applicable = guard(input)
139
+ } catch {
140
+ applicable = false
141
+ }
142
+ if (!applicable) {
143
+ const gate = getGateDescription(capability as Capability, id)
144
+ return {
145
+ ok: false,
146
+ error: buildGatedError(id, gate, capability, available, input),
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ providers: resolved,
156
+ ...(Object.keys(matchedFrom).length > 0 ? { matchedFrom } : {}),
157
+ }
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Gate descriptions — used to produce accurate user-facing messages.
162
+ //
163
+ // Two kinds:
164
+ // requires → provider needs a field that the input does NOT have
165
+ // incompatible_with → provider is excluded BECAUSE a field IS present
166
+ //
167
+ // The distinction matters: "requires X" tells the user to ADD something;
168
+ // "not compatible with X" tells the user to REMOVE something.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ interface GateDesc {
172
+ kind: 'requires' | 'incompatible_with'
173
+ fields: string
174
+ }
175
+
176
+ const GENERIC_REQUIRES: GateDesc = { kind: 'requires', fields: 'specific input fields' }
177
+
178
+ const GATED_DESCRIPTIONS: Partial<Record<Capability, Partial<Record<string, GateDesc>>>> = {
179
+ // -------------------------------------------------------------------------
180
+ search_companies: {
181
+ theirstack: { kind: 'requires', fields: 'technologies, industries, or funding stage filters' },
182
+ signalbase: { kind: 'incompatible_with', fields: 'technology, funding, revenue, or hiring signal filters' },
183
+ blitzapi: { kind: 'incompatible_with', fields: 'advanced filters (tech stack, funding, revenue, exclusion lists, or workforce growth)' },
184
+ apollo: { kind: 'incompatible_with', fields: 'year, tech stack, funding, revenue, exclusion, or workforce growth filters' },
185
+ predictleads: { kind: 'requires', fields: 'both a location filter and an employee size filter' },
186
+ sumble: { kind: 'requires', fields: 'keywords, industries, or technologies' },
187
+ 'limadata-prospect-filter': { kind: 'requires', fields: 'specific employee count range filters (min_employees / max_employees)' },
188
+ 'limadata-prospect-url': { kind: 'requires', fields: 'linkedin_search_url' },
189
+ 'linkupapi-fundraising': { kind: 'requires', fields: 'funding filters and keywords or industries' },
190
+ 'linkupapi-hiring': { kind: 'requires', fields: 'is_hiring: true' },
191
+ 'prospeo-search-company': { kind: 'requires', fields: 'keywords, industries, or countries' },
192
+ 'ai-ark-companies': { kind: 'requires', fields: 'keywords, industries, or countries' },
193
+ },
194
+ // -------------------------------------------------------------------------
195
+ find_people: {
196
+ 'linkupapi-search-profiles': { kind: 'incompatible_with', fields: 'company_linkedin_urls or company_domains (this provider only works without company filters)' },
197
+ 'sumble-people-find': { kind: 'requires', fields: 'company_domains or company_linkedin_urls, and job_titles or seniorities' },
198
+ 'prospeo-search-person': { kind: 'requires', fields: 'job_titles or company_domains' },
199
+ 'ai-ark-people': { kind: 'requires', fields: 'job_titles, seniorities, or keywords' },
200
+ 'findymail-search-employees': { kind: 'requires', fields: 'company_domains and job_titles' },
201
+ },
202
+ // -------------------------------------------------------------------------
203
+ find_email: {
204
+ findymail: { kind: 'requires', fields: 'first_name and (domain or company_name)' },
205
+ 'limadata-work-email': { kind: 'requires', fields: 'first_name, last_name, and domain' },
206
+ blitzapi: { kind: 'requires', fields: 'linkedin_url' },
207
+ 'limadata-work-email-linkedin': { kind: 'requires', fields: 'linkedin_url' },
208
+ },
209
+ // -------------------------------------------------------------------------
210
+ verify_email: {},
211
+ // -------------------------------------------------------------------------
212
+ find_phone: {
213
+ findymail: { kind: 'requires', fields: 'linkedin_url' },
214
+ limadata: { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and company domain)' },
215
+ 'ai-ark': { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and company domain)' },
216
+ },
217
+ // -------------------------------------------------------------------------
218
+ enrich_company: {
219
+ companyenrich: { kind: 'requires', fields: 'domain' },
220
+ apollo: { kind: 'requires', fields: 'domain' },
221
+ limadata: { kind: 'requires', fields: 'domain or linkedin_url' },
222
+ wiza: { kind: 'requires', fields: 'domain or name' },
223
+ companyenrich_props: { kind: 'requires', fields: 'name or linkedin_url' },
224
+ blitzapi: { kind: 'requires', fields: 'linkedin_url' },
225
+ icypeas: { kind: 'requires', fields: 'linkedin_url' },
226
+ builtwith: { kind: 'requires', fields: 'domain' },
227
+ openmart: { kind: 'requires', fields: 'domain' },
228
+ 'linkupapi-by-domain': { kind: 'requires', fields: 'domain' },
229
+ 'linkupapi-by-url': { kind: 'requires', fields: 'linkedin_url' },
230
+ },
231
+ // -------------------------------------------------------------------------
232
+ enrich_person: {
233
+ 'linkupapi-profile-enrich': { kind: 'requires', fields: 'first_name, last_name, and (company_name or domain)' },
234
+ 'linkupapi-email-reverse': { kind: 'requires', fields: 'email' },
235
+ 'blitzapi-reverse-email': { kind: 'requires', fields: 'email' },
236
+ 'findymail-business-profile': { kind: 'requires', fields: 'linkedin_url (must be a linkedin.com URL)' },
237
+ 'findymail-reverse-email': { kind: 'requires', fields: 'email' },
238
+ 'icypeas-scrape-profile': { kind: 'requires', fields: 'linkedin_url (must be a linkedin.com URL)' },
239
+ 'icypeas-url-search-profile': { kind: 'requires', fields: 'first_name, last_name, and (company_name or domain)' },
240
+ 'ai-ark-reverse-lookup': { kind: 'requires', fields: 'email or phone' },
241
+ 'icypeas-reverse-email-lookup': { kind: 'requires', fields: 'email' },
242
+ },
243
+ // -------------------------------------------------------------------------
244
+ search_web: {},
245
+ // -------------------------------------------------------------------------
246
+ search_jobs: {
247
+ career_site_jobs: { kind: 'incompatible_with', fields: 'LinkedIn-only filters (seniority_levels, industries, organization_slugs, min/max_employees, easy_apply_only, exclude_easy_apply)' },
248
+ linkedin_jobs_api: { kind: 'incompatible_with', fields: 'Career Site-only filters (ats_slugs, company_domains)' },
249
+ 'theirstack-jobs': { kind: 'requires', fields: 'specific job filter conditions' },
250
+ },
251
+ // -------------------------------------------------------------------------
252
+ search_ads: {
253
+ google_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input); use query, domains, or advertiser_ids instead' },
254
+ linkedin_ad_library: { kind: 'requires', fields: 'search_urls (pre-built LinkedIn Ad Library URLs)' },
255
+ meta_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
256
+ twitter_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
257
+ reddit_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
258
+ },
259
+ // -------------------------------------------------------------------------
260
+ search_places: {
261
+ openmart: { kind: 'requires', fields: 'country in [US, CA, AU, PR, NZ] or structured Openmart filters' },
262
+ google_maps: { kind: 'requires', fields: 'a query or start_urls' },
263
+ },
264
+ // -------------------------------------------------------------------------
265
+ find_influencers: {
266
+ influencers_similar: { kind: 'requires', fields: 'handle (a creator handle for lookalike search)' },
267
+ },
268
+ // -------------------------------------------------------------------------
269
+ search_reddit: {},
270
+ // -------------------------------------------------------------------------
271
+ search_seo: {
272
+ kw_search_volume: { kind: 'requires', fields: 'category: "keywords"' },
273
+ kw_trends: { kind: 'requires', fields: 'category: "keywords"' },
274
+ serp_google: { kind: 'requires', fields: 'category: "serp" and engine: "google"' },
275
+ serp_bing: { kind: 'requires', fields: 'category: "serp" and engine: "bing"' },
276
+ serp_youtube: { kind: 'requires', fields: 'category: "serp" and engine: "youtube"' },
277
+ bl_summary: { kind: 'requires', fields: 'category: "backlinks"' },
278
+ bl_backlinks: { kind: 'requires', fields: 'category: "backlinks"' },
279
+ bl_referring: { kind: 'requires', fields: 'category: "backlinks"' },
280
+ domain_tech: { kind: 'requires', fields: 'category: "domain" and action: "technologies"' },
281
+ domain_whois: { kind: 'requires', fields: 'category: "domain" and action: "whois"' },
282
+ labs_rank_overview: { kind: 'requires', fields: 'category: "labs" and target' },
283
+ labs_ranked_kw: { kind: 'requires', fields: 'category: "labs" and target' },
284
+ labs_competitors: { kind: 'requires', fields: 'category: "labs" and target' },
285
+ labs_kw_ideas: { kind: 'requires', fields: 'category: "labs" and keywords[]' },
286
+ page_lighthouse: { kind: 'requires', fields: 'category: "page", url, and page_action: "lighthouse"' },
287
+ page_content: { kind: 'requires', fields: 'category: "page", url, and page_action: "content"' },
288
+ },
289
+ // -------------------------------------------------------------------------
290
+ find_signals: {
291
+ 'signalbase-funding': { kind: 'requires', fields: 'signal_type: "funding"' },
292
+ 'signalbase-acquisition': { kind: 'requires', fields: 'signal_type: "acquisition"' },
293
+ 'signalbase-hiring': { kind: 'requires', fields: 'signal_type: "hiring"' },
294
+ 'signalbase-job-change': { kind: 'requires', fields: 'signal_type: "job_change"' },
295
+ 'theirstack-buying-intents': { kind: 'requires', fields: 'signal_type: "intent" and at least one of companies or domains' },
296
+ 'predictleads-financing': { kind: 'requires', fields: 'signal_type: "funding"' },
297
+ 'predictleads-news': { kind: 'requires', fields: 'signal_type: "news"' },
298
+ 'predictleads-startup-posts': { kind: 'requires', fields: 'signal_type: "startup_post"' },
299
+ },
300
+ // -------------------------------------------------------------------------
301
+ fetch_page_content: {},
302
+ }
303
+
304
+ function getGateDescription(capability: Capability, providerId: string): GateDesc {
305
+ return GATED_DESCRIPTIONS[capability]?.[providerId] ?? GENERIC_REQUIRES
306
+ }
@@ -324,17 +324,19 @@ describe('executor — 402 propagation', () => {
324
324
  vi.restoreAllMocks()
325
325
  })
326
326
 
327
- it('surfaces billingUrl + insufficientCredits when every provider returns 402', async () => {
327
+ it('surfaces billingUrl + insufficientCredits when every provider returns 402 (balance > 0)', async () => {
328
+ // User has some credits but every provider's cost exceeds it — waterfall
329
+ // tries each, all return 402, no early bail (balance > 0).
328
330
  stubProviders([
329
331
  makeProvider({ id: 'p1', hasResult: () => false }),
330
332
  makeProvider({ id: 'p2', hasResult: () => false }),
331
333
  ])
332
334
 
333
335
  const body = {
334
- error: "You don't have enough credits to run this request — it costs 5 credits and your balance is 0. Top up your account at https://coldiq.com/marketplace/billing to continue.",
336
+ error: "You don't have enough credits to run this request — it costs 50 credits and your balance is 2. Top up your account at https://coldiq.com/marketplace/billing to continue.",
335
337
  billingUrl: 'https://coldiq.com/marketplace/billing',
336
- needed: 5,
337
- balance: 0,
338
+ needed: 50,
339
+ balance: 2,
338
340
  }
339
341
  globalThis.fetch = vi.fn(async () =>
340
342
  new Response(JSON.stringify(body), { status: 402 })
@@ -381,6 +383,72 @@ describe('executor — 402 propagation', () => {
381
383
  }
382
384
  })
383
385
 
386
+ it('short-circuits the waterfall when first 402 reports balance: 0', async () => {
387
+ stubProviders([
388
+ makeProvider({ id: 'p1', hasResult: () => false }),
389
+ makeProvider({ id: 'p2', hasResult: () => false }),
390
+ makeProvider({ id: 'p3', hasResult: () => false }),
391
+ ])
392
+
393
+ const fetchMock = vi.fn(async () =>
394
+ new Response(
395
+ JSON.stringify({
396
+ error: 'no credits',
397
+ billingUrl: 'https://coldiq.com/marketplace/billing',
398
+ needed: 1,
399
+ balance: 0,
400
+ }),
401
+ { status: 402 }
402
+ )
403
+ ) as typeof fetch
404
+ globalThis.fetch = fetchMock
405
+
406
+ const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
407
+
408
+ // Only the first provider was tried — p2 and p3 were skipped
409
+ expect(fetchMock).toHaveBeenCalledTimes(1)
410
+
411
+ expect('error' in result).toBe(true)
412
+ if ('error' in result) {
413
+ expect(result.insufficientCredits).toBe(true)
414
+ expect(result.billingUrl).toBe('https://coldiq.com/marketplace/billing')
415
+ expect(result.providers_tried).toHaveLength(1)
416
+ expect(result.error).toContain('balance is 0')
417
+ }
418
+ })
419
+
420
+ it('does NOT short-circuit when first 402 reports a non-zero balance', async () => {
421
+ stubProviders([
422
+ makeProvider({ id: 'p1', hasResult: () => false }),
423
+ makeProvider({ id: 'p2', hasResult: () => false }),
424
+ ])
425
+
426
+ let call = 0
427
+ const fetchMock = vi.fn(async () => {
428
+ call++
429
+ // p1 returns 402 (cost too high) but balance > 0 → keep trying
430
+ if (call === 1) {
431
+ return new Response(
432
+ JSON.stringify({
433
+ error: 'cost too high',
434
+ billingUrl: 'https://coldiq.com/marketplace/billing',
435
+ needed: 5,
436
+ balance: 2,
437
+ }),
438
+ { status: 402 }
439
+ )
440
+ }
441
+ // p2 succeeds with the user's 2 remaining credits
442
+ return new Response(JSON.stringify({ ok: true, data: ['result'] }), { status: 200 })
443
+ }) as typeof fetch
444
+ globalThis.fetch = fetchMock
445
+
446
+ await executeWithFallback('search_companies', { keywords: ['SaaS'] })
447
+
448
+ // Both providers were tried — non-zero balance means a cheaper provider might fit
449
+ expect(fetchMock).toHaveBeenCalledTimes(2)
450
+ })
451
+
384
452
  it('preserves the full 402 message in providers_tried (not truncated mid-URL)', async () => {
385
453
  stubProviders([makeProvider({ id: 'p1', hasResult: () => false })])
386
454
 
@@ -475,5 +543,117 @@ describe('executor async polling intervals', () => {
475
543
 
476
544
  })
477
545
 
546
+ // ---------------------------------------------------------------------------
547
+ // options.providers — pinned provider routing
548
+ // ---------------------------------------------------------------------------
549
+
550
+ describe('executeWithFallback with options.providers', () => {
551
+ const originalFetch = globalThis.fetch
552
+
553
+ beforeEach(() => {
554
+ initClient('http://test-api.local', 'test-key-123')
555
+ })
556
+
557
+ afterEach(() => {
558
+ globalThis.fetch = originalFetch
559
+ vi.restoreAllMocks()
560
+ })
561
+
562
+ it('only calls the pinned provider, skips others', async () => {
563
+ const callOrder: string[] = []
564
+ stubProviders([
565
+ makeProvider({ id: 'first', priority: 1, hasResult: () => false }),
566
+ makeProvider({ id: 'second', priority: 2, hasResult: () => true }),
567
+ makeProvider({ id: 'third', priority: 3, hasResult: () => false }),
568
+ ])
569
+
570
+ globalThis.fetch = vi.fn(async (url: unknown) => {
571
+ const path = (url as string).replace('http://test-api.local', '')
572
+ callOrder.push(path)
573
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
574
+ }) as typeof fetch
575
+
576
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' }, { providers: ['second'] })
577
+
578
+ expect(callOrder).toHaveLength(1)
579
+ expect('data' in result).toBe(true)
580
+ if ('data' in result) expect(result._meta.provider).toBe('second')
581
+ })
582
+
583
+ it('respects user-specified order when multiple providers given', async () => {
584
+ let callCount = 0
585
+ stubProviders([
586
+ makeProvider({ id: 'alpha', priority: 1, hasResult: () => false }),
587
+ makeProvider({ id: 'beta', priority: 2, hasResult: (d) => callCount >= 2 && (d as Record<string, unknown>).ok === true }),
588
+ ])
589
+
590
+ globalThis.fetch = vi.fn(async () => {
591
+ callCount++
592
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
593
+ }) as typeof fetch
594
+
595
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' }, { providers: ['alpha', 'beta'] })
596
+
597
+ expect(callCount).toBe(2)
598
+ expect('data' in result).toBe(true)
599
+ if ('data' in result) expect(result._meta.provider).toBe('beta')
600
+ })
601
+
602
+ it('on total failure with pinned providers, returns non-anonymized provider IDs', async () => {
603
+ stubProviders([
604
+ makeProvider({ id: 'specific-provider', priority: 1, hasResult: () => false }),
605
+ ])
606
+
607
+ globalThis.fetch = vi.fn(async () =>
608
+ new Response(JSON.stringify({ error: 'down' }), { status: 500 })
609
+ ) as typeof fetch
610
+
611
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' }, { providers: ['specific-provider'] })
612
+
613
+ expect('error' in result).toBe(true)
614
+ if ('error' in result) {
615
+ expect(result.providers_tried[0].provider).toBe('specific-provider')
616
+ }
617
+ })
618
+
619
+ it('on total failure without pinned providers, still anonymizes provider IDs', async () => {
620
+ stubProviders([
621
+ makeProvider({ id: 'secret-provider', priority: 1, hasResult: () => false }),
622
+ ])
623
+
624
+ globalThis.fetch = vi.fn(async () =>
625
+ new Response(JSON.stringify({ error: 'down' }), { status: 500 })
626
+ ) as typeof fetch
627
+
628
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
629
+
630
+ expect('error' in result).toBe(true)
631
+ if ('error' in result) {
632
+ expect(result.providers_tried[0].provider).toBe('provider_1')
633
+ }
634
+ })
635
+
636
+ it('surfaces matchedFrom in success _meta when provided', async () => {
637
+ stubProviders([
638
+ makeProvider({ id: 'prospeo', priority: 1, hasResult: () => true }),
639
+ ])
640
+
641
+ globalThis.fetch = vi.fn(async () =>
642
+ new Response(JSON.stringify({ ok: true }), { status: 200 })
643
+ ) as typeof fetch
644
+
645
+ const result = await executeWithFallback(
646
+ 'enrich_company',
647
+ { domain: 'coldiq.com' },
648
+ { providers: ['prospeo'], matchedFrom: { prospec: 'prospeo' } },
649
+ )
650
+
651
+ expect('data' in result).toBe(true)
652
+ if ('data' in result) {
653
+ expect(result._meta.matchedFrom).toEqual({ prospec: 'prospeo' })
654
+ }
655
+ })
656
+ })
657
+
478
658
  // Note: the LeadsFactory backoff *schedule* itself is asserted against the live
479
659
  // provider entry in tests/registry.test.ts to avoid drift between test and source.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { getProviders, getSearchWebProviders, isoCountryToName } from '../src/registry.js'
2
+ import { getProviders, getSearchWebProviders, isoCountryToName, listCapabilityProviderIds } from '../src/registry.js'
3
3
  import type { Capability } from '../src/registry.js'
4
4
 
5
5
  const ALL_CAPABILITIES: Capability[] = [
@@ -2149,3 +2149,24 @@ describe('registry', () => {
2149
2149
  })
2150
2150
  })
2151
2151
  })
2152
+
2153
+ describe('listCapabilityProviderIds', () => {
2154
+ it('returns at least one provider ID for every capability', () => {
2155
+ const caps: Capability[] = [
2156
+ 'search_companies', 'find_people', 'find_email', 'verify_email', 'find_phone',
2157
+ 'enrich_company', 'enrich_person', 'search_web', 'search_jobs', 'search_ads',
2158
+ 'search_places', 'find_influencers', 'search_reddit', 'search_seo',
2159
+ 'find_signals', 'fetch_page_content',
2160
+ ]
2161
+ for (const cap of caps) {
2162
+ const ids = listCapabilityProviderIds(cap)
2163
+ expect(ids.length, `${cap} should have at least 1 provider`).toBeGreaterThan(0)
2164
+ }
2165
+ })
2166
+
2167
+ it('returns string IDs that match getProviders output order', () => {
2168
+ const ids = listCapabilityProviderIds('find_email')
2169
+ const providers = getProviders('find_email')
2170
+ expect(ids).toEqual(providers.map((p) => p.id))
2171
+ })
2172
+ })
@@ -91,4 +91,47 @@ describe('find_email handler (waterfall)', () => {
91
91
  // No linkedin_url → blitzapi and limadata-work-email-linkedin are skipped via isApplicable
92
92
  expect(parsed.providers_tried.length).toBe(6) // findymail, icypeas, limadata-work-email, prospeo, fullenrich, linkupapi
93
93
  })
94
+
95
+ describe('use_providers', () => {
96
+ it('Scenario A — pinned provider succeeds', async () => {
97
+ globalThis.fetch = vi.fn(async () =>
98
+ new Response(JSON.stringify({ email: 'michel@coldiq.com' }), { status: 200 })
99
+ ) as typeof fetch
100
+
101
+ const result = await findEmailHandler({
102
+ first_name: 'Michel',
103
+ domain: 'coldiq.com',
104
+ use_providers: ['findymail'],
105
+ })
106
+
107
+ expect(result.isError).toBeUndefined()
108
+ const parsed = JSON.parse(result.content[0].text)
109
+ expect(parsed._meta.provider).toBe('findymail')
110
+ })
111
+
112
+ it('Scenario C — gated provider without required input returns helpful error', async () => {
113
+ const result = await findEmailHandler({
114
+ first_name: 'Michel',
115
+ domain: 'coldiq.com',
116
+ use_providers: ['blitzapi'],
117
+ })
118
+
119
+ expect(result.isError).toBe(true)
120
+ const parsed = JSON.parse(result.content[0].text)
121
+ expect(parsed.error).toContain("'blitzapi' requires")
122
+ expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
123
+ })
124
+
125
+ it('Scenario E — unrecognized provider returns isError', async () => {
126
+ const result = await findEmailHandler({
127
+ first_name: 'Michel',
128
+ domain: 'coldiq.com',
129
+ use_providers: ['apollo'],
130
+ })
131
+
132
+ expect(result.isError).toBe(true)
133
+ const parsed = JSON.parse(result.content[0].text)
134
+ expect(parsed.error).toContain("'apollo' is not a recognized provider")
135
+ })
136
+ })
94
137
  })