diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 4aaf902ffa..998f1c5dcf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -19,6 +19,7 @@ import { AnthropicIcon, BrandfetchIcon, ExaAIIcon, + FindymailIcon, FirecrawlIcon, FireworksIcon, GeminiIcon, @@ -32,7 +33,9 @@ import { ParallelIcon, PeopleDataLabsIcon, PerplexityIcon, + ProspeoIcon, SerperIcon, + WizaIcon, } from '@/components/icons' import { Input } from '@/components/ui' import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' @@ -172,6 +175,27 @@ const PROVIDERS: { description: 'Person and company enrichment, search, and identity', placeholder: 'Enter your People Data Labs API key', }, + { + id: 'findymail', + name: 'Findymail', + icon: FindymailIcon, + description: 'Email finder, verification, and phone lookup', + placeholder: 'Enter your Findymail API key', + }, + { + id: 'prospeo', + name: 'Prospeo', + icon: ProspeoIcon, + description: 'Person and company enrichment and search', + placeholder: 'Enter your Prospeo API key', + }, + { + id: 'wiza', + name: 'Wiza', + icon: WizaIcon, + description: 'Prospect search, individual reveal, and company enrichment', + placeholder: 'Enter your Wiza API key', + }, ] export function BYOK() { diff --git a/apps/sim/blocks/blocks/findymail.ts b/apps/sim/blocks/blocks/findymail.ts index 4f8bea4911..a112cf5ee8 100644 --- a/apps/sim/blocks/blocks/findymail.ts +++ b/apps/sim/blocks/blocks/findymail.ts @@ -214,7 +214,7 @@ export const FindymailBlock: BlockConfig = { placeholder: 'e.g. React, TypeScript, Node.js', }, }, - // API Key + // API Key — hidden on hosted Sim for operations with hosted-key support { id: 'apiKey', title: 'API Key', @@ -222,6 +222,18 @@ export const FindymailBlock: BlockConfig = { required: true, placeholder: 'Enter your Findymail API key', password: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'findymail_get_credits', not: true }, + }, + // API Key — always required for the credit-balance lookup (no hosted key) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Findymail API key', + password: true, + condition: { field: 'operation', value: 'findymail_get_credits' }, }, ], tools: { diff --git a/apps/sim/blocks/blocks/prospeo.ts b/apps/sim/blocks/blocks/prospeo.ts index 2980efdb96..b28fc3417a 100644 --- a/apps/sim/blocks/blocks/prospeo.ts +++ b/apps/sim/blocks/blocks/prospeo.ts @@ -302,7 +302,7 @@ export const ProspeoBlock: BlockConfig = { condition: { field: 'operation', value: 'prospeo_search_suggestions' }, }, - // API Key (always last) + // API Key — hidden on hosted Sim for operations with hosted-key support { id: 'apiKey', title: 'API Key', @@ -310,6 +310,25 @@ export const ProspeoBlock: BlockConfig = { required: true, placeholder: 'Enter your Prospeo API key', password: true, + hideWhenHosted: true, + condition: { + field: 'operation', + value: ['prospeo_search_suggestions', 'prospeo_account_information'], + not: true, + }, + }, + // API Key — always required for the free account/suggestion lookups (no hosted key) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Prospeo API key', + password: true, + condition: { + field: 'operation', + value: ['prospeo_search_suggestions', 'prospeo_account_information'], + }, }, ], tools: { diff --git a/apps/sim/blocks/blocks/wiza.ts b/apps/sim/blocks/blocks/wiza.ts index 91a1acdff8..fe648c0f7a 100644 --- a/apps/sim/blocks/blocks/wiza.ts +++ b/apps/sim/blocks/blocks/wiza.ts @@ -24,8 +24,7 @@ export const WizaBlock: BlockConfig = { options: [ { label: 'Prospect Search', id: 'prospect_search' }, { label: 'Company Enrichment', id: 'company_enrichment' }, - { label: 'Start Individual Reveal', id: 'start_individual_reveal' }, - { label: 'Get Individual Reveal', id: 'get_individual_reveal' }, + { label: 'Individual Reveal', id: 'individual_reveal' }, { label: 'Get Credits', id: 'get_credits' }, ], value: () => 'prospect_search', @@ -37,6 +36,17 @@ export const WizaBlock: BlockConfig = { placeholder: 'Enter your Wiza API key', password: true, required: true, + hideWhenHosted: true, + condition: { field: 'operation', value: 'get_credits', not: true }, + }, + { + id: 'apiKey', + title: 'Wiza API Key', + type: 'short-input', + placeholder: 'Enter your Wiza API key', + password: true, + required: true, + condition: { field: 'operation', value: 'get_credits' }, }, // Prospect Search @@ -287,7 +297,7 @@ Return ONLY the JSON object - no explanations, no extra text.`, mode: 'advanced', }, - // Start Individual Reveal + // Individual Reveal { id: 'enrichment_level', title: 'Enrichment Level', @@ -298,85 +308,66 @@ Return ONLY the JSON object - no explanations, no extra text.`, { label: 'Phone', id: 'phone' }, { label: 'Full', id: 'full' }, ], - value: () => 'partial', - condition: { field: 'operation', value: 'start_individual_reveal' }, - required: { field: 'operation', value: 'start_individual_reveal' }, + value: () => 'full', + condition: { field: 'operation', value: 'individual_reveal' }, + required: { field: 'operation', value: 'individual_reveal' }, }, { id: 'profile_url', title: 'LinkedIn Profile URL', type: 'short-input', placeholder: 'https://linkedin.com/in/johndoe', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'full_name', title: 'Full Name', type: 'short-input', placeholder: 'John Doe', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'company', title: 'Company', type: 'short-input', placeholder: 'Wiza', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'domain', title: 'Company Domain', type: 'short-input', placeholder: 'wiza.co', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'email', title: 'Email', type: 'short-input', placeholder: 'john@wiza.co', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, }, { id: 'accept_work', title: 'Accept Work Emails', type: 'switch', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, mode: 'advanced', }, { id: 'accept_personal', title: 'Accept Personal Emails', type: 'switch', - condition: { field: 'operation', value: 'start_individual_reveal' }, - mode: 'advanced', - }, - { - id: 'callback_url', - title: 'Callback URL', - type: 'short-input', - placeholder: 'https://example.com/wiza-callback', - condition: { field: 'operation', value: 'start_individual_reveal' }, + condition: { field: 'operation', value: 'individual_reveal' }, mode: 'advanced', }, - - // Get Individual Reveal - { - id: 'id', - title: 'Reveal ID', - type: 'short-input', - placeholder: 'Reveal ID returned from Start Individual Reveal', - condition: { field: 'operation', value: 'get_individual_reveal' }, - required: { field: 'operation', value: 'get_individual_reveal' }, - }, ], tools: { access: [ 'wiza_prospect_search', 'wiza_company_enrichment', - 'wiza_start_individual_reveal', - 'wiza_get_individual_reveal', + 'wiza_individual_reveal', 'wiza_get_credits', ], config: { @@ -386,10 +377,8 @@ Return ONLY the JSON object - no explanations, no extra text.`, return 'wiza_prospect_search' case 'company_enrichment': return 'wiza_company_enrichment' - case 'start_individual_reveal': - return 'wiza_start_individual_reveal' - case 'get_individual_reveal': - return 'wiza_get_individual_reveal' + case 'individual_reveal': + return 'wiza_individual_reveal' case 'get_credits': return 'wiza_get_credits' default: @@ -479,8 +468,6 @@ Return ONLY the JSON object - no explanations, no extra text.`, email: { type: 'string', description: 'Email address' }, accept_work: { type: 'boolean', description: 'Whether to accept work emails' }, accept_personal: { type: 'boolean', description: 'Whether to accept personal emails' }, - callback_url: { type: 'string', description: 'Callback URL' }, - id: { type: 'string', description: 'Individual reveal ID' }, }, outputs: { @@ -495,46 +482,44 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, id: { type: 'number', - description: 'Reveal ID (start_individual_reveal, get_individual_reveal)', + description: 'Reveal ID (individual_reveal)', }, status: { type: 'string', - description: - 'Reveal status (start_individual_reveal, get_individual_reveal): queued | resolving | finished | failed', + description: 'Reveal status (individual_reveal): queued | resolving | finished | failed', }, is_complete: { type: 'boolean', - description: - 'Whether the reveal has completed (start_individual_reveal, get_individual_reveal)', + description: 'Whether the reveal has completed (individual_reveal)', }, - name: { type: 'string', description: 'Full name (get_individual_reveal)' }, - company: { type: 'string', description: 'Company name (get_individual_reveal)' }, + name: { type: 'string', description: 'Full name (individual_reveal)' }, + company: { type: 'string', description: 'Company name (individual_reveal)' }, enrichment_level: { type: 'string', - description: 'Enrichment level used (get_individual_reveal)', + description: 'Enrichment level used (individual_reveal)', }, - linkedin_profile_url: { type: 'string', description: 'LinkedIn URL (get_individual_reveal)' }, - title: { type: 'string', description: 'Job title (get_individual_reveal)' }, - location: { type: 'string', description: 'Location (get_individual_reveal)' }, - email: { type: 'string', description: 'Primary email (get_individual_reveal)' }, - email_type: { type: 'string', description: 'Primary email type (get_individual_reveal)' }, + linkedin_profile_url: { type: 'string', description: 'LinkedIn URL (individual_reveal)' }, + title: { type: 'string', description: 'Job title (individual_reveal)' }, + location: { type: 'string', description: 'Location (individual_reveal)' }, + email: { type: 'string', description: 'Primary email (individual_reveal)' }, + email_type: { type: 'string', description: 'Primary email type (individual_reveal)' }, email_status: { type: 'string', - description: 'Primary email status: valid | risky | unfound (get_individual_reveal)', + description: 'Primary email status: valid | risky | unfound (individual_reveal)', }, emails: { type: 'json', - description: 'All emails found (get_individual_reveal): [{email, email_type, email_status}]', + description: 'All emails found (individual_reveal): [{email, email_type, email_status}]', }, - mobile_phone: { type: 'string', description: 'Mobile phone (get_individual_reveal)' }, - phone_number: { type: 'string', description: 'Direct/office phone (get_individual_reveal)' }, + mobile_phone: { type: 'string', description: 'Mobile phone (individual_reveal)' }, + phone_number: { type: 'string', description: 'Direct/office phone (individual_reveal)' }, phone_status: { type: 'string', - description: 'Phone status: found | unfound (get_individual_reveal)', + description: 'Phone status: found | unfound (individual_reveal)', }, phones: { type: 'json', - description: 'All phones found (get_individual_reveal): [{number, pretty_number, type}]', + description: 'All phones found (individual_reveal): [{number, pretty_number, type}]', }, company_name: { type: 'string', @@ -542,41 +527,41 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, company_domain: { type: 'string', - description: 'Company domain (company_enrichment, get_individual_reveal)', + description: 'Company domain (company_enrichment, individual_reveal)', }, domain: { type: 'string', description: 'Domain (company_enrichment)' }, company_industry: { type: 'string', - description: 'Industry (company_enrichment, get_individual_reveal)', + description: 'Industry (company_enrichment, individual_reveal)', }, company_size: { type: 'number', - description: 'Employee count (company_enrichment, get_individual_reveal)', + description: 'Employee count (company_enrichment, individual_reveal)', }, company_size_range: { type: 'string', - description: 'Headcount range (company_enrichment, get_individual_reveal)', + description: 'Headcount range (company_enrichment, individual_reveal)', }, company_founded: { type: 'number', - description: 'Year founded (company_enrichment, get_individual_reveal)', + description: 'Year founded (company_enrichment, individual_reveal)', }, company_revenue_range: { type: 'string', description: 'Revenue range (company_enrichment)', }, - company_revenue: { type: 'string', description: 'Revenue (get_individual_reveal)' }, + company_revenue: { type: 'string', description: 'Revenue (individual_reveal)' }, company_funding: { type: 'string', - description: 'Total funding (company_enrichment, get_individual_reveal)', + description: 'Total funding (company_enrichment, individual_reveal)', }, company_type: { type: 'string', - description: 'Company type (company_enrichment, get_individual_reveal)', + description: 'Company type (company_enrichment, individual_reveal)', }, company_description: { type: 'string', - description: 'Company description (company_enrichment, get_individual_reveal)', + description: 'Company description (company_enrichment, individual_reveal)', }, company_ticker: { type: 'string', description: 'Stock ticker (company_enrichment)' }, company_last_funding_round: { @@ -593,40 +578,40 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, company_location: { type: 'string', - description: 'Full location string (company_enrichment, get_individual_reveal)', + description: 'Full location string (company_enrichment, individual_reveal)', }, company_twitter: { type: 'string', description: 'Twitter URL (company_enrichment)' }, company_facebook: { type: 'string', description: 'Facebook URL (company_enrichment)' }, company_linkedin: { type: 'string', - description: 'LinkedIn URL (company_enrichment, get_individual_reveal)', + description: 'LinkedIn URL (company_enrichment, individual_reveal)', }, company_linkedin_id: { type: 'string', description: 'LinkedIn ID (company_enrichment)' }, company_street: { type: 'string', - description: 'Street address (company_enrichment, get_individual_reveal)', + description: 'Street address (company_enrichment, individual_reveal)', }, company_locality: { type: 'string', - description: 'City (company_enrichment, get_individual_reveal)', + description: 'City (company_enrichment, individual_reveal)', }, company_region: { type: 'string', - description: 'State/region (company_enrichment, get_individual_reveal)', + description: 'State/region (company_enrichment, individual_reveal)', }, company_postal_code: { type: 'string', - description: 'Postal code (company_enrichment, get_individual_reveal)', + description: 'Postal code (company_enrichment, individual_reveal)', }, company_country: { type: 'string', - description: 'Country (company_enrichment, get_individual_reveal)', + description: 'Country (company_enrichment, individual_reveal)', }, - company_subindustry: { type: 'string', description: 'Subindustry (get_individual_reveal)' }, + company_subindustry: { type: 'string', description: 'Subindustry (individual_reveal)' }, credits: { type: 'json', description: - 'Credits deducted — company_enrichment: { api_credits: { total, company_credits } }; get_individual_reveal: { api_credits: { total, email_credits, phone_credits, scrape_credits } }', + 'Credits deducted — company_enrichment: { api_credits: { total, company_credits } }; individual_reveal: { api_credits: { total, email_credits, phone_credits, scrape_credits } }', }, email_credits: { type: 'json', diff --git a/apps/sim/enrichments/phone-number/phone-number.test.ts b/apps/sim/enrichments/phone-number/phone-number.test.ts new file mode 100644 index 0000000000..2aa82c33ee --- /dev/null +++ b/apps/sim/enrichments/phone-number/phone-number.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { phoneNumberEnrichment } from '@/enrichments/phone-number/phone-number' +import type { EnrichmentProvider } from '@/enrichments/types' + +function provider(id: string): EnrichmentProvider { + const p = phoneNumberEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in phone-number cascade`) + return p +} + +const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' } +const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' } + +describe('phone-number enrichment cascade', () => { + it('chains PDL then the phone-capable hosted providers', () => { + expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual([ + 'pdl', + 'wiza', + 'findymail', + 'prospeo', + ]) + }) + + describe('wiza (opportunistic)', () => { + const p = provider('wiza') + it('reveals phone, using name+domain or LinkedIn profile_url', () => { + expect(p.toolId).toBe('wiza_individual_reveal') + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + domain: 'acme.com', + enrichment_level: 'phone', + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + profile_url: 'https://linkedin.com/in/johndoe', + enrichment_level: 'phone', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ mobile_phone: '+1555', phones: [] })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ phones: [{ number: '+1777' }] })).toEqual({ phone: '+1777' }) + }) + }) + + describe('findymail', () => { + const p = provider('findymail') + it('keys off the LinkedIn URL and skips without one', () => { + expect(p.toolId).toBe('findymail_find_phone') + expect(p.buildParams(linkedinOnly)).toEqual({ + linkedin_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams(nameDomain)).toBeNull() + expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' }) + expect(p.mapOutput({ phone: null })).toBeNull() + }) + }) + + describe('prospeo (opportunistic)', () => { + const p = provider('prospeo') + it('requests mobile enrichment via name+domain or LinkedIn', () => { + expect(p.toolId).toBe('prospeo_enrich_person') + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + company_website: 'acme.com', + enrich_mobile: true, + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + linkedin_url: 'https://linkedin.com/in/johndoe', + enrich_mobile: true, + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' }) + }) + }) +}) diff --git a/apps/sim/enrichments/phone-number/phone-number.ts b/apps/sim/enrichments/phone-number/phone-number.ts index 5b76e12e80..6a7ad186cd 100644 --- a/apps/sim/enrichments/phone-number/phone-number.ts +++ b/apps/sim/enrichments/phone-number/phone-number.ts @@ -4,17 +4,22 @@ import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments import type { EnrichmentConfig } from '@/enrichments/types' /** - * Phone Number enrichment. Finds a contact's phone number from their full name - * and (optionally) company domain via a People Data Labs person match. + * Phone Number enrichment. Finds a contact's phone number from a full name plus + * any available identifiers (company domain, LinkedIn URL) via a waterfall: + * People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo + * mobile. Each provider opportunistically uses whatever identifiers the row + * provides and self-skips when it has none usable, so adding more inputs widens + * coverage without reordering. First phone wins; all providers support hosted keys. */ export const phoneNumberEnrichment: EnrichmentConfig = { id: 'phone-number', name: 'Phone Number', - description: "Find a contact's phone number from their name and company domain.", + description: "Find a contact's phone number from their name, company, or LinkedIn URL.", icon: Phone, inputs: [ { id: 'fullName', name: 'Full name', type: 'string', required: true }, { id: 'companyDomain', name: 'Company domain', type: 'string' }, + { id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' }, ], outputs: [{ id: 'phone', name: 'phone', type: 'string' }], providers: [ @@ -37,5 +42,69 @@ export const phoneNumberEnrichment: EnrichmentConfig = { return phone ? { phone } : null }, }), + toolProvider({ + id: 'wiza', + label: 'Wiza', + toolId: 'wiza_individual_reveal', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + // Needs a LinkedIn URL or a name+domain pair; skip otherwise. + if (!linkedin && !(fullName && domain)) return null + // 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present. + return filterUndefined({ + profile_url: linkedin || undefined, + full_name: fullName || undefined, + domain: domain || undefined, + enrichment_level: 'phone', + }) + }, + mapOutput: (output) => { + const phones = Array.isArray(output.phones) + ? (output.phones as Record[]) + : [] + const phone = str(output.mobile_phone) || str(output.phone_number) || str(phones[0]?.number) + return phone ? { phone } : null + }, + }), + toolProvider({ + id: 'findymail', + label: 'Findymail', + toolId: 'findymail_find_phone', + buildParams: (inputs) => { + // Findymail's phone finder keys off a LinkedIn URL only. + const linkedin = str(inputs.linkedinUrl) + if (!linkedin) return null + return { linkedin_url: linkedin } + }, + mapOutput: (output) => { + const phone = str(output.phone) + return phone ? { phone } : null + }, + }), + toolProvider({ + id: 'prospeo', + label: 'Prospeo', + toolId: 'prospeo_enrich_person', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const companyWebsite = normalizeDomain(inputs.companyDomain) + if (!linkedin && !(fullName && companyWebsite)) return null + return filterUndefined({ + linkedin_url: linkedin || undefined, + full_name: fullName || undefined, + company_website: companyWebsite || undefined, + enrich_mobile: true, + }) + }, + mapOutput: (output) => { + const person = output.person as Record | undefined + const mobile = person?.mobile as Record | undefined + const phone = str(mobile?.mobile) + return phone ? { phone } : null + }, + }), ], } diff --git a/apps/sim/enrichments/work-email/work-email.test.ts b/apps/sim/enrichments/work-email/work-email.test.ts new file mode 100644 index 0000000000..41bf50bc23 --- /dev/null +++ b/apps/sim/enrichments/work-email/work-email.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { EnrichmentProvider } from '@/enrichments/types' +import { workEmailEnrichment } from '@/enrichments/work-email/work-email' + +function provider(id: string): EnrichmentProvider { + const p = workEmailEnrichment.providers.find((x) => x.id === id) + if (!p) throw new Error(`Provider ${id} not found in work-email cascade`) + return p +} + +const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' } +const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' } + +describe('work-email enrichment cascade', () => { + it('chains the hosted providers in waterfall order', () => { + expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([ + 'hunter', + 'findymail', + 'findymail-linkedin', + 'prospeo', + 'wiza', + 'pdl', + ]) + }) + + describe('findymail (name)', () => { + const p = provider('findymail') + it('maps name + domain and extracts contact.email', () => { + expect(p.toolId).toBe('findymail_find_email_from_name') + expect(p.buildParams(nameDomain)).toEqual({ name: 'John Doe', domain: 'acme.com' }) + expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' }) + expect(p.buildParams(linkedinOnly)).toBeNull() + }) + }) + + describe('findymail-linkedin', () => { + const p = provider('findymail-linkedin') + it('keys off the LinkedIn URL and skips without one', () => { + expect(p.toolId).toBe('findymail_find_email_from_linkedin') + expect(p.buildParams(linkedinOnly)).toEqual({ + linkedin_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams(nameDomain)).toBeNull() + expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' }) + }) + }) + + describe('prospeo (opportunistic)', () => { + const p = provider('prospeo') + it('uses name+domain, or LinkedIn when present', () => { + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + company_website: 'acme.com', + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + linkedin_url: 'https://linkedin.com/in/johndoe', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ person: { email: { email: 'j@acme.com' } } })).toEqual({ + email: 'j@acme.com', + }) + }) + }) + + describe('wiza (opportunistic)', () => { + const p = provider('wiza') + it('reveals email-only (partial), preferring LinkedIn profile_url', () => { + expect(p.buildParams(nameDomain)).toEqual({ + full_name: 'John Doe', + domain: 'acme.com', + enrichment_level: 'partial', + }) + expect(p.buildParams(linkedinOnly)).toEqual({ + full_name: 'John Doe', + profile_url: 'https://linkedin.com/in/johndoe', + enrichment_level: 'partial', + }) + expect(p.buildParams({ fullName: 'John Doe' })).toBeNull() + expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' }) + }) + }) +}) diff --git a/apps/sim/enrichments/work-email/work-email.ts b/apps/sim/enrichments/work-email/work-email.ts index 0e998406c3..4166217a1c 100644 --- a/apps/sim/enrichments/work-email/work-email.ts +++ b/apps/sim/enrichments/work-email/work-email.ts @@ -4,18 +4,23 @@ import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/pro import type { EnrichmentConfig } from '@/enrichments/types' /** - * Work Email enrichment. Finds a person's work email from their full name and - * company domain, trying Hunter first (deterministic finder) then People Data - * Labs (record match) as a fallback. + * Work Email enrichment. Finds a person's work email from a full name plus any + * available identifiers (company domain, LinkedIn URL) via a provider waterfall: + * deterministic finders first (Hunter, Findymail by name then by LinkedIn), then + * enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad + * record-match fallback. Each provider opportunistically uses whatever + * identifiers the row provides and self-skips when it has none usable, so adding + * more inputs widens coverage. First email wins; all providers support hosted keys. */ export const workEmailEnrichment: EnrichmentConfig = { id: 'work-email', name: 'Work Email', - description: "Find a person's work email from their name and company domain.", + description: "Find a person's work email from their name, company, or LinkedIn URL.", icon: Mail, inputs: [ { id: 'fullName', name: 'Full name', type: 'string', required: true }, - { id: 'companyDomain', name: 'Company domain', type: 'string', required: true }, + { id: 'companyDomain', name: 'Company domain', type: 'string' }, + { id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' }, ], outputs: [{ id: 'email', name: 'email', type: 'string' }], providers: [ @@ -34,6 +39,81 @@ export const workEmailEnrichment: EnrichmentConfig = { return email ? { email } : null }, }), + toolProvider({ + id: 'findymail', + label: 'Findymail', + toolId: 'findymail_find_email_from_name', + buildParams: (inputs) => { + const name = str(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + if (!name || !domain) return null + return { name, domain } + }, + mapOutput: (output) => { + const contact = output.contact as Record | null + const email = str(contact?.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'findymail-linkedin', + label: 'Findymail (LinkedIn)', + toolId: 'findymail_find_email_from_linkedin', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + if (!linkedin) return null + return { linkedin_url: linkedin } + }, + mapOutput: (output) => { + const contact = output.contact as Record | null + const email = str(contact?.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'prospeo', + label: 'Prospeo', + toolId: 'prospeo_enrich_person', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const companyWebsite = normalizeDomain(inputs.companyDomain) + if (!linkedin && !(fullName && companyWebsite)) return null + return filterUndefined({ + linkedin_url: linkedin || undefined, + full_name: fullName || undefined, + company_website: companyWebsite || undefined, + }) + }, + mapOutput: (output) => { + const person = output.person as Record | undefined + const emailObj = person?.email as Record | undefined + const email = str(emailObj?.email) + return email ? { email } : null + }, + }), + toolProvider({ + id: 'wiza', + label: 'Wiza', + toolId: 'wiza_individual_reveal', + buildParams: (inputs) => { + const linkedin = str(inputs.linkedinUrl) + const fullName = str(inputs.fullName) + const domain = normalizeDomain(inputs.companyDomain) + if (!linkedin && !(fullName && domain)) return null + // 'partial' reveals the email only (2 credits); avoids phone charges. + return filterUndefined({ + profile_url: linkedin || undefined, + full_name: fullName || undefined, + domain: domain || undefined, + enrichment_level: 'partial', + }) + }, + mapOutput: (output) => { + const email = str(output.email) + return email ? { email } : null + }, + }), toolProvider({ id: 'pdl', label: 'People Data Labs', diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index d52838eb42..0f79dcb217 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -20,6 +20,9 @@ export const byokProviderIdSchema = z.enum([ 'cohere', 'hunter', 'peopledatalabs', + 'findymail', + 'prospeo', + 'wiza', ]) export const byokKeySchema = z.object({ diff --git a/apps/sim/tools/enrichment-hosting.test.ts b/apps/sim/tools/enrichment-hosting.test.ts new file mode 100644 index 0000000000..fcb33b31d0 --- /dev/null +++ b/apps/sim/tools/enrichment-hosting.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { findEmailFromNameTool } from '@/tools/findymail/find_email_from_name' +import { findEmailsByDomainTool } from '@/tools/findymail/find_emails_by_domain' +import { findPhoneTool } from '@/tools/findymail/find_phone' +import { FINDYMAIL_CREDIT_USD } from '@/tools/findymail/hosting' +import { reverseEmailLookupTool } from '@/tools/findymail/reverse_email_lookup' +import { verifyEmailTool } from '@/tools/findymail/verify_email' +import { bulkEnrichPersonTool } from '@/tools/prospeo/bulk_enrich_person' +import { enrichCompanyTool } from '@/tools/prospeo/enrich_company' +import { enrichPersonTool } from '@/tools/prospeo/enrich_person' +import { PROSPEO_CREDIT_USD } from '@/tools/prospeo/hosting' +import { searchPersonTool } from '@/tools/prospeo/search_person' +import type { ToolConfig } from '@/tools/types' +import { wizaCompanyEnrichmentTool } from '@/tools/wiza/company_enrichment' +import { WIZA_CREDIT_USD } from '@/tools/wiza/hosting' +import { wizaIndividualRevealTool } from '@/tools/wiza/individual_reveal' +import { wizaProspectSearchTool } from '@/tools/wiza/prospect_search' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function cost(tool: ToolConfig, params: any, output: Record) { + const pricing = tool.hosting?.pricing + if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing') + const result = pricing.getCost(params, output) + return typeof result === 'number' ? { cost: result } : result +} + +describe('Findymail hosted key pricing', () => { + it('declares hosting with the shared env prefix and BYOK provider', () => { + expect(findEmailFromNameTool.hosting?.envKeyPrefix).toBe('FINDYMAIL_API_KEY') + expect(findEmailFromNameTool.hosting?.byokProviderId).toBe('findymail') + }) + + it('charges one credit only when an email is found', () => { + expect(cost(findEmailFromNameTool, {}, { contact: { email: 'a@b.com' } }).cost).toBeCloseTo( + FINDYMAIL_CREDIT_USD + ) + expect(cost(findEmailFromNameTool, {}, { contact: null }).cost).toBe(0) + }) + + it('charges 10 credits for a found phone', () => { + expect(cost(findPhoneTool, {}, { phone: '+1555' }).cost).toBeCloseTo(10 * FINDYMAIL_CREDIT_USD) + expect(cost(findPhoneTool, {}, { phone: null }).cost).toBe(0) + }) + + it('charges one credit per contact returned by domain search', () => { + expect(cost(findEmailsByDomainTool, {}, { contacts: [{}, {}, {}] }).cost).toBeCloseTo( + 3 * FINDYMAIL_CREDIT_USD + ) + }) + + it('charges 2 credits for a reverse lookup with profile enrichment, 1 without', () => { + expect( + cost(reverseEmailLookupTool, { with_profile: true }, { email: 'a@b.com' }).cost + ).toBeCloseTo(2 * FINDYMAIL_CREDIT_USD) + expect( + cost(reverseEmailLookupTool, { with_profile: false }, { email: 'a@b.com' }).cost + ).toBeCloseTo(FINDYMAIL_CREDIT_USD) + expect( + cost(reverseEmailLookupTool, {}, { email: null, linkedin_url: null, fullName: null }).cost + ).toBe(0) + }) + + it('charges one verifier credit per verification', () => { + expect(cost(verifyEmailTool, {}, { verified: true }).cost).toBeCloseTo(FINDYMAIL_CREDIT_USD) + }) +}) + +describe('Prospeo hosted key pricing', () => { + it('declares hosting with the shared env prefix and BYOK provider', () => { + expect(enrichPersonTool.hosting?.envKeyPrefix).toBe('PROSPEO_API_KEY') + expect(enrichPersonTool.hosting?.byokProviderId).toBe('prospeo') + }) + + it('charges 1 credit for a person match and 10 when a mobile is revealed', () => { + expect(cost(enrichPersonTool, {}, { free_enrichment: false, person: {} }).cost).toBeCloseTo( + PROSPEO_CREDIT_USD + ) + expect( + cost(enrichPersonTool, {}, { free_enrichment: false, person: { mobile: { revealed: true } } }) + .cost + ).toBeCloseTo(10 * PROSPEO_CREDIT_USD) + }) + + it('does not charge on a free or no-match enrichment', () => { + expect(cost(enrichPersonTool, {}, { free_enrichment: true, person: {} }).cost).toBe(0) + expect(cost(enrichPersonTool, {}, { free_enrichment: false, person: null }).cost).toBe(0) + expect(cost(enrichCompanyTool, {}, { free_enrichment: false, company: null }).cost).toBe(0) + }) + + it('uses the API-reported total_cost for bulk endpoints', () => { + expect(cost(bulkEnrichPersonTool, {}, { total_cost: 7 }).cost).toBeCloseTo( + 7 * PROSPEO_CREDIT_USD + ) + }) + + it('throws when bulk total_cost is missing', () => { + expect(() => cost(bulkEnrichPersonTool, {}, { matched: [] })).toThrow(/total_cost/) + }) + + it('charges one credit per non-free search page with results', () => { + expect(cost(searchPersonTool, {}, { free: false, results: [{}] }).cost).toBeCloseTo( + PROSPEO_CREDIT_USD + ) + expect(cost(searchPersonTool, {}, { free: true, results: [{}] }).cost).toBe(0) + expect(cost(searchPersonTool, {}, { free: false, results: [] }).cost).toBe(0) + }) +}) + +describe('Wiza hosted key pricing', () => { + it('declares hosting with the shared env prefix and BYOK provider', () => { + expect(wizaIndividualRevealTool.hosting?.envKeyPrefix).toBe('WIZA_API_KEY') + expect(wizaIndividualRevealTool.hosting?.byokProviderId).toBe('wiza') + }) + + it('charges 2 credits for a valid email and 5 for a phone on individual reveal', () => { + expect( + cost(wizaIndividualRevealTool, {}, { email_status: 'valid', phones: [] }).cost + ).toBeCloseTo(2 * WIZA_CREDIT_USD) + expect( + cost(wizaIndividualRevealTool, {}, { email_status: 'unfound', mobile_phone: '+1555' }).cost + ).toBeCloseTo(5 * WIZA_CREDIT_USD) + expect( + cost(wizaIndividualRevealTool, {}, { email_status: 'valid', phones: [{ number: '+1555' }] }) + .cost + ).toBeCloseTo(7 * WIZA_CREDIT_USD) + expect(cost(wizaIndividualRevealTool, {}, { email_status: 'unfound', phones: [] }).cost).toBe(0) + }) + + it('charges 2 credits per company enrichment match and nothing for prospect search', () => { + expect(cost(wizaCompanyEnrichmentTool, {}, { company_name: 'Wiza' }).cost).toBeCloseTo( + 2 * WIZA_CREDIT_USD + ) + expect( + cost( + wizaCompanyEnrichmentTool, + {}, + { company_name: null, company_domain: null, domain: null } + ).cost + ).toBe(0) + expect(cost(wizaProspectSearchTool, {}, { total: 100, profiles: [] }).cost).toBe(0) + }) + + it('polls the reveal to completion in postProcess', async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + id: 123, + status: 'finished', + email: 'a@b.com', + email_status: 'valid', + emails: [], + phones: [], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 123, status: 'queued', is_complete: false } as any, + } + const promise = wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(2000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledWith( + 'https://wiza.co/api/individual_reveals/123', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer k' }) }) + ) + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('a@b.com') + expect((result.output as any).status).toBe('finished') + }) + + it('returns immediately without polling when the initial reveal is already terminal', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { + id: 123, + status: 'finished', + is_complete: true, + email: 'a@b.com', + email_status: 'valid', + emails: [], + phones: [], + } as any, + } + const result = await wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('a@b.com') + }) + + it('retries transient poll errors and still resolves on a later finished response', async () => { + vi.useFakeTimers() + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('busy', { status: 503 })) + .mockResolvedValueOnce(new Response('rate limited', { status: 429 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: 1, + status: 'finished', + email: 'a@b.com', + email_status: 'valid', + emails: [], + phones: [], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 1, status: 'queued', is_complete: false } as any, + } + const promise = wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(6000) + const result = await promise + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.success).toBe(true) + expect((result.output as any).email).toBe('a@b.com') + }) + + it('returns an explicit failure (not a queued success) after repeated poll errors', async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 })) + vi.stubGlobal('fetch', fetchMock) + + const initial = { + success: true as const, + output: { id: 1, status: 'queued', is_complete: false } as any, + } + const promise = wizaIndividualRevealTool.postProcess!( + initial as any, + { apiKey: 'k', enrichment_level: 'full' } as any, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(6000) + const result = await promise + + expect(result.success).toBe(false) + expect((result.output as any).status).toBe('queued') + }) +}) diff --git a/apps/sim/tools/findymail/find_email_from_linkedin.ts b/apps/sim/tools/findymail/find_email_from_linkedin.ts index 87eb569f96..5d095cd9cf 100644 --- a/apps/sim/tools/findymail/find_email_from_linkedin.ts +++ b/apps/sim/tools/findymail/find_email_from_linkedin.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmailFromLinkedInParams, FindymailFindEmailFromLinkedInResponse, @@ -15,6 +16,11 @@ export const findEmailFromLinkedInTool: ToolConfig< "Find someone's email from a LinkedIn profile URL or username. Uses one finder credit when a verified email is found.", version: '1.0.0', + hosting: findymailHosting((_params, output) => { + const contact = output.contact as { email?: string } | null + return contact?.email ? 1 : 0 + }), + params: { linkedin_url: { type: 'string', diff --git a/apps/sim/tools/findymail/find_email_from_name.ts b/apps/sim/tools/findymail/find_email_from_name.ts index 5a22a19c3b..6a09ed75bd 100644 --- a/apps/sim/tools/findymail/find_email_from_name.ts +++ b/apps/sim/tools/findymail/find_email_from_name.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmailFromNameParams, FindymailFindEmailFromNameResponse, @@ -15,6 +16,11 @@ export const findEmailFromNameTool: ToolConfig< "Find someone's email from their name and a company domain or company name. Uses one finder credit when a verified email is found.", version: '1.0.0', + hosting: findymailHosting((_params, output) => { + const contact = output.contact as { email?: string } | null + return contact?.email ? 1 : 0 + }), + params: { name: { type: 'string', diff --git a/apps/sim/tools/findymail/find_emails_by_domain.ts b/apps/sim/tools/findymail/find_emails_by_domain.ts index ebb1de7ee3..de26fd4f0a 100644 --- a/apps/sim/tools/findymail/find_emails_by_domain.ts +++ b/apps/sim/tools/findymail/find_emails_by_domain.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmailsByDomainParams, FindymailFindEmailsByDomainResponse, @@ -15,6 +16,15 @@ export const findEmailsByDomainTool: ToolConfig< 'Find verified contacts at a given domain matching one or more target roles (max 3 roles). Limited to 5 concurrent synchronous requests.', version: '1.0.0', + hosting: findymailHosting((_params, output) => { + // No contacts array means no verified contacts returned — no charge. + if (!Array.isArray(output.contacts)) { + return 0 + } + // 1 finder credit per verified contact returned. + return output.contacts.length + }), + params: { domain: { type: 'string', diff --git a/apps/sim/tools/findymail/find_employees.ts b/apps/sim/tools/findymail/find_employees.ts index aabd08ce47..9051ad8d75 100644 --- a/apps/sim/tools/findymail/find_employees.ts +++ b/apps/sim/tools/findymail/find_employees.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindEmployeesParams, FindymailFindEmployeesResponse, @@ -15,6 +16,15 @@ export const findEmployeesTool: ToolConfig< 'Find employees at a company by website and target job titles. Uses 1 credit per found contact. Does not return email addresses.', version: '1.0.0', + hosting: findymailHosting((_params, output) => { + // No employees array means no contacts found — no charge. + if (!Array.isArray(output.employees)) { + return 0 + } + // 1 finder credit per contact found. + return output.employees.length + }), + params: { website: { type: 'string', diff --git a/apps/sim/tools/findymail/find_phone.ts b/apps/sim/tools/findymail/find_phone.ts index 0222eeed79..8a5c48f159 100644 --- a/apps/sim/tools/findymail/find_phone.ts +++ b/apps/sim/tools/findymail/find_phone.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailFindPhoneParams, FindymailFindPhoneResponse } from '@/tools/findymail/types' import type { ToolConfig } from '@/tools/types' @@ -8,6 +9,11 @@ export const findPhoneTool: ToolConfig((_params, output) => { + // Phone lookups consume 10 finder credits, only when a number is found. + return output.phone ? 10 : 0 + }), + params: { linkedin_url: { type: 'string', diff --git a/apps/sim/tools/findymail/get_company.ts b/apps/sim/tools/findymail/get_company.ts index 2ee001f852..64c03bcf83 100644 --- a/apps/sim/tools/findymail/get_company.ts +++ b/apps/sim/tools/findymail/get_company.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailGetCompanyParams, FindymailGetCompanyResponse, @@ -11,6 +12,11 @@ export const getCompanyTool: ToolConfig((_params, output) => { + // 1 finder credit per successful company match. + return output.name || output.domain ? 1 : 0 + }), + params: { linkedin_url: { type: 'string', diff --git a/apps/sim/tools/findymail/hosting.ts b/apps/sim/tools/findymail/hosting.ts new file mode 100644 index 0000000000..e624c13163 --- /dev/null +++ b/apps/sim/tools/findymail/hosting.ts @@ -0,0 +1,42 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Findymail hosted keys. Provide keys as + * `FINDYMAIL_API_KEY_COUNT` plus `FINDYMAIL_API_KEY_1..N`. + */ +export const FINDYMAIL_API_KEY_PREFIX = 'FINDYMAIL_API_KEY' + +/** + * Dollar cost of a single Findymail finder credit. + * + * Findymail charges per verified result: 1 credit per email, 10 credits per + * phone, and only when a result is found. Estimated from the $99/month Starter + * plan (5,000 credits ≈ $0.0198/credit) — https://www.findymail.com/pricing/. + */ +export const FINDYMAIL_CREDIT_USD = 0.02 + +/** + * Build a Findymail `hosting` config. `getCredits` returns the number of + * Findymail credits the call consumed, derived from the tool's output (per the + * documented per-endpoint credit model at https://www.findymail.com/api/). + */ +export function findymailHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: FINDYMAIL_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'findymail', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * FINDYMAIL_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/findymail/lookup_technologies.ts b/apps/sim/tools/findymail/lookup_technologies.ts index b7fb2af8d1..e7360f3615 100644 --- a/apps/sim/tools/findymail/lookup_technologies.ts +++ b/apps/sim/tools/findymail/lookup_technologies.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailLookupTechnologiesParams, FindymailLookupTechnologiesResponse, @@ -15,6 +16,11 @@ export const lookupTechnologiesTool: ToolConfig< 'Get the technology stack for a company by domain. Optionally filter by technology names. 1 finder credit if technologies are found, free otherwise.', version: '1.0.0', + hosting: findymailHosting((_params, output) => { + // 1 finder credit when a technology stack is returned, free otherwise. + return Array.isArray(output.technologies) && output.technologies.length > 0 ? 1 : 0 + }), + params: { domain: { type: 'string', diff --git a/apps/sim/tools/findymail/reverse_email_lookup.ts b/apps/sim/tools/findymail/reverse_email_lookup.ts index 48a0c42ca1..447d112b69 100644 --- a/apps/sim/tools/findymail/reverse_email_lookup.ts +++ b/apps/sim/tools/findymail/reverse_email_lookup.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailReverseEmailLookupParams, FindymailReverseEmailLookupResponse, @@ -14,6 +15,13 @@ export const reverseEmailLookupTool: ToolConfig< 'Find a business profile from an email address. Uses 1 finder credit if a profile is found, 2 credits if returning full profile data.', version: '1.0.0', + hosting: findymailHosting((params, output) => { + const found = Boolean(output.email || output.linkedin_url || output.fullName) + if (!found) return 0 + // 1 credit for a match, 2 when full profile enrichment is requested. + return params.with_profile ? 2 : 1 + }), + params: { email: { type: 'string', diff --git a/apps/sim/tools/findymail/search_technologies.ts b/apps/sim/tools/findymail/search_technologies.ts index a4f7bcadcc..64d56b785a 100644 --- a/apps/sim/tools/findymail/search_technologies.ts +++ b/apps/sim/tools/findymail/search_technologies.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailSearchTechnologiesParams, FindymailSearchTechnologiesResponse, @@ -15,6 +16,11 @@ export const searchTechnologiesTool: ToolConfig< 'Search the technology catalog by name. Returns up to 25 technologies. Free endpoint, rate limited to 10 requests per minute.', version: '1.0.0', + hosting: findymailHosting(() => { + // Free catalog search — consumes no Findymail credits. + return 0 + }), + params: { q: { type: 'string', diff --git a/apps/sim/tools/findymail/verify_email.ts b/apps/sim/tools/findymail/verify_email.ts index c239e296cd..30463e1fe6 100644 --- a/apps/sim/tools/findymail/verify_email.ts +++ b/apps/sim/tools/findymail/verify_email.ts @@ -1,3 +1,4 @@ +import { findymailHosting } from '@/tools/findymail/hosting' import type { FindymailVerifyEmailParams, FindymailVerifyEmailResponse, @@ -11,6 +12,11 @@ export const verifyEmailTool: ToolConfig(() => { + // Each verification consumes one verifier credit, billed at the finder-credit rate. + return 1 + }), + params: { email: { type: 'string', diff --git a/apps/sim/tools/prospeo/bulk_enrich_company.ts b/apps/sim/tools/prospeo/bulk_enrich_company.ts index c73c6e70f1..f202392162 100644 --- a/apps/sim/tools/prospeo/bulk_enrich_company.ts +++ b/apps/sim/tools/prospeo/bulk_enrich_company.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoBulkEnrichCompanyParams, @@ -15,6 +16,14 @@ export const bulkEnrichCompanyTool: ToolConfig< description: 'Enrich up to 50 company records at once.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // Prospeo reports the exact credits spent for the batch in total_cost. + if (typeof output.total_cost !== 'number') { + throw new Error('Prospeo bulk enrich company response missing total_cost') + } + return output.total_cost + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/bulk_enrich_person.ts b/apps/sim/tools/prospeo/bulk_enrich_person.ts index b6d095e952..e594e9b7cb 100644 --- a/apps/sim/tools/prospeo/bulk_enrich_person.ts +++ b/apps/sim/tools/prospeo/bulk_enrich_person.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoBulkEnrichPersonParams, @@ -15,6 +16,14 @@ export const bulkEnrichPersonTool: ToolConfig< description: 'Enrich up to 50 person records at once.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // Prospeo reports the exact credits spent for the batch in total_cost. + if (typeof output.total_cost !== 'number') { + throw new Error('Prospeo bulk enrich person response missing total_cost') + } + return output.total_cost + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/enrich_company.ts b/apps/sim/tools/prospeo/enrich_company.ts index ca4e1ed9b9..7f0b141e3d 100644 --- a/apps/sim/tools/prospeo/enrich_company.ts +++ b/apps/sim/tools/prospeo/enrich_company.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoEnrichCompanyParams, @@ -14,6 +15,12 @@ export const enrichCompanyTool: ToolConfig< description: 'Enrich a company with complete B2B data.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // 1 credit per company match; no charge on a no-match or repeat enrichment. + if (output.free_enrichment === true) return 0 + return output.company ? 1 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/enrich_person.ts b/apps/sim/tools/prospeo/enrich_person.ts index 44c72a2600..e072dc6bbb 100644 --- a/apps/sim/tools/prospeo/enrich_person.ts +++ b/apps/sim/tools/prospeo/enrich_person.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, type ProspeoEnrichPersonParams, @@ -12,6 +13,16 @@ export const enrichPersonTool: ToolConfig((_params, output) => { + // No charge on a no-match or a repeat enrichment. + if (output.free_enrichment === true) return 0 + const person = output.person as Record | null + if (!person) return 0 + // 10 credits when a mobile is revealed, otherwise 1 for the person match. + const mobile = person.mobile as { revealed?: boolean } | undefined + return mobile?.revealed ? 10 : 1 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/hosting.ts b/apps/sim/tools/prospeo/hosting.ts new file mode 100644 index 0000000000..c5d97b9847 --- /dev/null +++ b/apps/sim/tools/prospeo/hosting.ts @@ -0,0 +1,43 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Prospeo hosted keys. Provide keys as + * `PROSPEO_API_KEY_COUNT` plus `PROSPEO_API_KEY_1..N`. + */ +export const PROSPEO_API_KEY_PREFIX = 'PROSPEO_API_KEY' + +/** + * Dollar cost of a single Prospeo credit. + * + * Prospeo charges per match: 1 credit per person/company match, 10 credits when + * a mobile is revealed, and never on a no-match or a repeat enrichment. Based on + * the $39/month Starter plan (1,000 credits ≈ $0.039/credit) — https://prospeo.io/pricing. + */ +export const PROSPEO_CREDIT_USD = 0.039 + +/** + * Build a Prospeo `hosting` config. `getCredits` returns the number of Prospeo + * credits the call consumed, derived from the tool's output (prefer the + * API-reported `total_cost` for bulk endpoints; otherwise compute from the + * `free`/`free_enrichment` flag and the match). + */ +export function prospeoHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: PROSPEO_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'prospeo', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * PROSPEO_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/prospeo/search_company.ts b/apps/sim/tools/prospeo/search_company.ts index f4e8a1c0b5..e16ecaf83c 100644 --- a/apps/sim/tools/prospeo/search_company.ts +++ b/apps/sim/tools/prospeo/search_company.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, PROSPEO_PAGINATION_OUTPUT, @@ -16,6 +17,13 @@ export const searchCompanyTool: ToolConfig< description: 'Search for companies using 20+ filters to build account lists.', version: '1.0.0', + hosting: prospeoHosting((_params, output) => { + // 1 credit per page that returns at least one result; free on 30-day dedup. + if (output.free === true) return 0 + const results = output.results + return Array.isArray(results) && results.length > 0 ? 1 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/prospeo/search_person.ts b/apps/sim/tools/prospeo/search_person.ts index c8415ceec9..883b11e73d 100644 --- a/apps/sim/tools/prospeo/search_person.ts +++ b/apps/sim/tools/prospeo/search_person.ts @@ -1,3 +1,4 @@ +import { prospeoHosting } from '@/tools/prospeo/hosting' import { extractProspeoError, PROSPEO_PAGINATION_OUTPUT, @@ -14,6 +15,13 @@ export const searchPersonTool: ToolConfig((_params, output) => { + // 1 credit per page that returns at least one result; free on 30-day dedup. + if (output.free === true) return 0 + const results = output.results + return Array.isArray(results) && results.length > 0 ? 1 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3070576fa4..d6558b4942 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3053,9 +3053,8 @@ import { import { wizaCompanyEnrichmentTool, wizaGetCreditsTool, - wizaGetIndividualRevealTool, + wizaIndividualRevealTool, wizaProspectSearchTool, - wizaStartIndividualRevealTool, } from '@/tools/wiza' import { wordpressCreateCategoryTool, @@ -5436,9 +5435,8 @@ export const tools: Record = { wikipedia_random: wikipediaRandomPageTool, wiza_company_enrichment: wizaCompanyEnrichmentTool, wiza_get_credits: wizaGetCreditsTool, - wiza_get_individual_reveal: wizaGetIndividualRevealTool, + wiza_individual_reveal: wizaIndividualRevealTool, wiza_prospect_search: wizaProspectSearchTool, - wiza_start_individual_reveal: wizaStartIndividualRevealTool, wordpress_create_post: wordpressCreatePostTool, wordpress_update_post: wordpressUpdatePostTool, wordpress_delete_post: wordpressDeletePostTool, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index b89ca98cd7..24501cb6d9 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -21,6 +21,9 @@ export type BYOKProviderId = | 'cohere' | 'hunter' | 'peopledatalabs' + | 'findymail' + | 'prospeo' + | 'wiza' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' @@ -309,7 +312,7 @@ export type ToolHostingPricing

> = PerRequestPricing * Adding more keys only requires updating the count and adding the new env var — * no code changes needed. */ -interface ToolHostingConfig

> { +export interface ToolHostingConfig

> { /** Optional predicate for tools where hosted keys only apply to some parameter combinations. */ enabled?: (params: P) => boolean /** diff --git a/apps/sim/tools/wiza/company_enrichment.ts b/apps/sim/tools/wiza/company_enrichment.ts index 70bfdfec5b..39089404f1 100644 --- a/apps/sim/tools/wiza/company_enrichment.ts +++ b/apps/sim/tools/wiza/company_enrichment.ts @@ -1,4 +1,5 @@ import type { ToolConfig } from '@/tools/types' +import { wizaHosting } from '@/tools/wiza/hosting' import type { WizaCompanyEnrichmentParams, WizaCompanyEnrichmentResponse } from '@/tools/wiza/types' export const wizaCompanyEnrichmentTool: ToolConfig< @@ -11,6 +12,11 @@ export const wizaCompanyEnrichmentTool: ToolConfig< 'Enrich a company by name, domain, LinkedIn ID, or LinkedIn slug with detailed firmographic data', version: '1.0.0', + hosting: wizaHosting((_params, output) => { + // 2 API credits per successful company match; no charge on a no-match. + return output.company_name || output.company_domain || output.domain ? 2 : 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/wiza/get_individual_reveal.ts b/apps/sim/tools/wiza/get_individual_reveal.ts deleted file mode 100644 index 7c3d4ec543..0000000000 --- a/apps/sim/tools/wiza/get_individual_reveal.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { ToolConfig } from '@/tools/types' -import type { - WizaGetIndividualRevealParams, - WizaGetIndividualRevealResponse, -} from '@/tools/wiza/types' - -export const wizaGetIndividualRevealTool: ToolConfig< - WizaGetIndividualRevealParams, - WizaGetIndividualRevealResponse -> = { - id: 'wiza_get_individual_reveal', - name: 'Wiza Get Individual Reveal', - description: 'Retrieve the status and enriched data for an individual reveal by ID', - version: '1.0.0', - - params: { - apiKey: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'Wiza API key', - }, - id: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Individual reveal ID returned from Start Individual Reveal', - }, - }, - - request: { - url: (params: WizaGetIndividualRevealParams) => - `https://wiza.co/api/individual_reveals/${encodeURIComponent(String(params.id).trim())}`, - method: 'GET', - headers: (params: WizaGetIndividualRevealParams) => ({ - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), - }, - - transformResponse: async (response: Response) => { - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Wiza API error: ${response.status} - ${errorText}`) - } - - const json = await response.json() - const d = json.data ?? {} - const emails = Array.isArray(d.emails) ? d.emails : [] - const phones = Array.isArray(d.phones) ? d.phones : [] - - return { - success: true, - output: { - id: d.id ?? null, - status: d.status ?? null, - is_complete: d.is_complete ?? null, - name: d.name ?? null, - company: d.company ?? null, - enrichment_level: d.enrichment_level ?? null, - linkedin_profile_url: d.linkedin_profile_url ?? null, - title: d.title ?? null, - location: d.location ?? null, - email: d.email ?? null, - email_type: d.email_type ?? null, - email_status: d.email_status ?? null, - emails: emails.map((e: Record) => ({ - email: (e.email as string) ?? null, - email_type: (e.email_type as string) ?? null, - email_status: (e.email_status as string) ?? null, - })), - mobile_phone: d.mobile_phone ?? null, - phone_number: d.phone_number ?? null, - phone_status: d.phone_status ?? null, - phones: phones.map((p: Record) => ({ - number: (p.number as string) ?? null, - pretty_number: (p.pretty_number as string) ?? null, - type: (p.type as string) ?? null, - })), - company_size: d.company_size ?? null, - company_size_range: d.company_size_range ?? null, - company_type: d.company_type ?? null, - company_domain: d.company_domain ?? null, - company_locality: d.company_locality ?? null, - company_region: d.company_region ?? null, - company_country: d.company_country ?? null, - company_street: d.company_street ?? null, - company_postal_code: d.company_postal_code ?? null, - company_founded: d.company_founded ?? null, - company_funding: d.company_funding ?? null, - company_revenue: d.company_revenue ?? null, - company_industry: d.company_industry ?? null, - company_subindustry: d.company_subindustry ?? null, - company_linkedin: d.company_linkedin ?? null, - company_location: d.company_location ?? null, - company_description: d.company_description ?? null, - credits: d.credits ?? null, - }, - } - }, - - outputs: { - id: { type: 'number', description: 'Reveal ID', optional: true }, - status: { - type: 'string', - description: 'queued | resolving | finished | failed', - optional: true, - }, - is_complete: { - type: 'boolean', - description: 'Whether the reveal has completed', - optional: true, - }, - name: { type: 'string', description: 'Full name', optional: true }, - company: { type: 'string', description: 'Company name', optional: true }, - enrichment_level: { type: 'string', description: 'Enrichment level used', optional: true }, - linkedin_profile_url: { type: 'string', description: 'LinkedIn URL', optional: true }, - title: { type: 'string', description: 'Job title', optional: true }, - location: { type: 'string', description: 'Location', optional: true }, - email: { type: 'string', description: 'Primary email', optional: true }, - email_type: { type: 'string', description: 'Email type', optional: true }, - email_status: { type: 'string', description: 'valid | risky | unfound', optional: true }, - emails: { - type: 'array', - description: 'All emails found', - optional: true, - items: { - type: 'object', - properties: { - email: { type: 'string' }, - email_type: { type: 'string' }, - email_status: { type: 'string' }, - }, - }, - }, - mobile_phone: { type: 'string', description: 'Mobile phone', optional: true }, - phone_number: { type: 'string', description: 'Direct/office phone', optional: true }, - phone_status: { type: 'string', description: 'found | unfound', optional: true }, - phones: { - type: 'array', - description: 'All phones found', - optional: true, - items: { - type: 'object', - properties: { - number: { type: 'string' }, - pretty_number: { type: 'string' }, - type: { type: 'string' }, - }, - }, - }, - company_size: { type: 'number', description: 'Employee count', optional: true }, - company_size_range: { type: 'string', description: 'Headcount range', optional: true }, - company_type: { type: 'string', description: 'Company type', optional: true }, - company_domain: { type: 'string', description: 'Company domain', optional: true }, - company_locality: { type: 'string', description: 'City', optional: true }, - company_region: { type: 'string', description: 'State/region', optional: true }, - company_country: { type: 'string', description: 'Country', optional: true }, - company_street: { type: 'string', description: 'Street', optional: true }, - company_postal_code: { type: 'string', description: 'Postal code', optional: true }, - company_founded: { type: 'number', description: 'Year founded', optional: true }, - company_funding: { type: 'string', description: 'Funding total', optional: true }, - company_revenue: { type: 'string', description: 'Revenue', optional: true }, - company_industry: { type: 'string', description: 'Industry', optional: true }, - company_subindustry: { type: 'string', description: 'Subindustry', optional: true }, - company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true }, - company_location: { type: 'string', description: 'Full company location', optional: true }, - company_description: { type: 'string', description: 'Company description', optional: true }, - credits: { - type: 'json', - description: - 'Credits deducted for this reveal (api_credits: { total, email_credits, phone_credits, scrape_credits })', - optional: true, - }, - }, -} diff --git a/apps/sim/tools/wiza/hosting.ts b/apps/sim/tools/wiza/hosting.ts new file mode 100644 index 0000000000..43fe367ae3 --- /dev/null +++ b/apps/sim/tools/wiza/hosting.ts @@ -0,0 +1,42 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** + * Env var prefix for Wiza hosted keys. Provide keys as `WIZA_API_KEY_COUNT` + * plus `WIZA_API_KEY_1..N`. + */ +export const WIZA_API_KEY_PREFIX = 'WIZA_API_KEY' + +/** + * Dollar cost of a single Wiza API credit. + * + * Wiza meters API usage in credits at a documented $0.025/credit (2,000-credit + * minimum) — https://help.wiza.co/en/articles/13551713-how-to-purchase-api-credits. + * Credits are deducted only when data is successfully returned: 2 credits per + * valid email, 5 credits per phone, 2 credits per company enrichment. + */ +export const WIZA_CREDIT_USD = 0.025 + +/** + * Build a Wiza `hosting` config. `getCredits` returns the number of Wiza API + * credits the call consumed, derived from the tool's output. + */ +export function wizaHosting

( + getCredits: (params: P, output: Record) => number +): ToolHostingConfig

{ + return { + envKeyPrefix: WIZA_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'wiza', + pricing: { + type: 'custom', + getCost: (params, output) => { + const credits = getCredits(params, output) + return { cost: credits * WIZA_CREDIT_USD, metadata: { credits } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 60, + }, + } +} diff --git a/apps/sim/tools/wiza/index.ts b/apps/sim/tools/wiza/index.ts index 21df6dec38..289503b4da 100644 --- a/apps/sim/tools/wiza/index.ts +++ b/apps/sim/tools/wiza/index.ts @@ -1,6 +1,5 @@ export { wizaCompanyEnrichmentTool } from './company_enrichment' export { wizaGetCreditsTool } from './get_credits' -export { wizaGetIndividualRevealTool } from './get_individual_reveal' +export { wizaIndividualRevealTool } from './individual_reveal' export { wizaProspectSearchTool } from './prospect_search' -export { wizaStartIndividualRevealTool } from './start_individual_reveal' export type * from './types' diff --git a/apps/sim/tools/wiza/individual_reveal.ts b/apps/sim/tools/wiza/individual_reveal.ts new file mode 100644 index 0000000000..291d1791f6 --- /dev/null +++ b/apps/sim/tools/wiza/individual_reveal.ts @@ -0,0 +1,330 @@ +import { sleep } from '@sim/utils/helpers' +import type { ToolConfig } from '@/tools/types' +import { wizaHosting } from '@/tools/wiza/hosting' +import type { + WizaIndividualRevealData, + WizaIndividualRevealParams, + WizaIndividualRevealResponse, +} from '@/tools/wiza/types' + +const POLL_INTERVAL_MS = 2000 +const MAX_POLL_TIME_MS = 120000 +/** Tolerate brief Wiza outages while polling before giving up on an already-started reveal. */ +const MAX_CONSECUTIVE_POLL_ERRORS = 3 + +/** Whether a reveal payload has reached a terminal state and no longer needs polling. */ +function isTerminalReveal(d: { status?: string | null; is_complete?: boolean | null }): boolean { + return d.status === 'finished' || d.status === 'failed' || d.is_complete === true +} + +/** Map a Wiza individual-reveal payload (`data` object) to the tool output shape. */ +function mapRevealData(d: Record): WizaIndividualRevealData { + const emails = Array.isArray(d.emails) ? (d.emails as Record[]) : [] + const phones = Array.isArray(d.phones) ? (d.phones as Record[]) : [] + return { + id: (d.id as number) ?? null, + status: (d.status as string) ?? null, + is_complete: (d.is_complete as boolean) ?? null, + name: (d.name as string) ?? null, + company: (d.company as string) ?? null, + enrichment_level: (d.enrichment_level as string) ?? null, + linkedin_profile_url: (d.linkedin_profile_url as string) ?? null, + title: (d.title as string) ?? null, + location: (d.location as string) ?? null, + email: (d.email as string) ?? null, + email_type: (d.email_type as string) ?? null, + email_status: (d.email_status as string) ?? null, + emails: emails.map((e) => ({ + email: (e.email as string) ?? null, + email_type: (e.email_type as string) ?? null, + email_status: (e.email_status as string) ?? null, + })), + mobile_phone: (d.mobile_phone as string) ?? null, + phone_number: (d.phone_number as string) ?? null, + phone_status: (d.phone_status as string) ?? null, + phones: phones.map((p) => ({ + number: (p.number as string) ?? null, + pretty_number: (p.pretty_number as string) ?? null, + type: (p.type as string) ?? null, + })), + company_size: (d.company_size as number) ?? null, + company_size_range: (d.company_size_range as string) ?? null, + company_type: (d.company_type as string) ?? null, + company_domain: (d.company_domain as string) ?? null, + company_locality: (d.company_locality as string) ?? null, + company_region: (d.company_region as string) ?? null, + company_country: (d.company_country as string) ?? null, + company_street: (d.company_street as string) ?? null, + company_postal_code: (d.company_postal_code as string) ?? null, + company_founded: (d.company_founded as number) ?? null, + company_funding: (d.company_funding as string) ?? null, + company_revenue: (d.company_revenue as string) ?? null, + company_industry: (d.company_industry as string) ?? null, + company_subindustry: (d.company_subindustry as string) ?? null, + company_linkedin: (d.company_linkedin as string) ?? null, + company_location: (d.company_location as string) ?? null, + company_description: (d.company_description as string) ?? null, + credits: (d.credits as Record) ?? null, + } +} + +export const wizaIndividualRevealTool: ToolConfig< + WizaIndividualRevealParams, + WizaIndividualRevealResponse +> = { + id: 'wiza_individual_reveal', + name: 'Wiza Individual Reveal', + description: + 'Reveal a contact via LinkedIn URL, name + company/domain, or email. Starts the reveal and polls until it resolves. Uses 2 credits per valid email and 5 credits per phone, charged only on success.', + version: '1.0.0', + + hosting: wizaHosting((_params, output) => { + let credits = 0 + const emails = Array.isArray(output.emails) + ? (output.emails as { email_status?: string }[]) + : [] + const emailValid = + output.email_status === 'valid' || emails.some((e) => e.email_status === 'valid') + // 2 credits when at least one valid email is returned. + if (emailValid) credits += 2 + const phones = Array.isArray(output.phones) ? output.phones : [] + const phoneFound = Boolean(output.mobile_phone || output.phone_number || phones.length > 0) + // 5 credits when at least one phone is returned. + if (phoneFound) credits += 5 + return credits + }), + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Wiza API key', + }, + enrichment_level: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Enrichment depth: none, partial, phone, or full', + }, + profile_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL (e.g., https://linkedin.com/in/johndoe)', + }, + full_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Full name (used with company or domain)', + }, + company: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (used with full_name)', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company domain (used with full_name)', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email address (use alone or with other identifiers)', + }, + accept_work: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to accept work emails (email_options)', + }, + accept_personal: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to accept personal emails (email_options)', + }, + }, + + request: { + url: 'https://wiza.co/api/individual_reveals', + method: 'POST', + headers: (params: WizaIndividualRevealParams) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params: WizaIndividualRevealParams) => { + const individual: Record = {} + if (params.profile_url) individual.profile_url = params.profile_url + if (params.full_name) individual.full_name = params.full_name + if (params.company) individual.company = params.company + if (params.domain) individual.domain = params.domain + if (params.email) individual.email = params.email + + const body: Record = { + individual_reveal: individual, + enrichment_level: params.enrichment_level, + } + + if (params.accept_work !== undefined || params.accept_personal !== undefined) { + const emailOptions: Record = {} + if (params.accept_work !== undefined) emailOptions.accept_work = params.accept_work + if (params.accept_personal !== undefined) { + emailOptions.accept_personal = params.accept_personal + } + body.email_options = emailOptions + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Wiza API error: ${response.status} - ${errorText}`) + } + const json = await response.json() + return { + success: true, + output: mapRevealData(json.data ?? {}), + } + }, + + postProcess: async (result, params) => { + if (!result.success) return result + + // Wiza can resolve synchronously (e.g. a cache hit) — the initial POST payload is + // already mapped, so skip polling when it is terminal. + if (isTerminalReveal(result.output)) { + return { success: result.output.status !== 'failed', output: result.output } + } + + const revealId = result.output.id + if (revealId == null) { + // Return an explicit failure rather than throwing: a thrown error here is swallowed + // by the executor and masked as the queued (incomplete) success result. + return { + success: false, + error: 'Wiza individual reveal did not return an id', + output: result.output, + } + } + + let elapsedTime = 0 + let consecutiveErrors = 0 + while (elapsedTime < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsedTime += POLL_INTERVAL_MS + + const statusResponse = await fetch( + `https://wiza.co/api/individual_reveals/${encodeURIComponent(String(revealId))}`, + { + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!statusResponse.ok) { + // The reveal is already started (and billed by Wiza), so tolerate brief outages and + // retry rather than aborting the whole window on a single transient 5xx/429. + consecutiveErrors += 1 + if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + const errorText = await statusResponse.text().catch(() => '') + return { + success: false, + error: `Wiza API error: ${statusResponse.status} - ${errorText}`, + output: result.output, + } + } + continue + } + consecutiveErrors = 0 + + const json = await statusResponse.json() + const data = json.data ?? {} + + if (isTerminalReveal(data)) { + return { + success: data.status !== 'failed', + output: mapRevealData(data), + } + } + } + + return { + success: false, + error: 'Wiza individual reveal did not complete within the polling window', + output: result.output, + } + }, + + outputs: { + id: { type: 'number', description: 'Reveal ID' }, + status: { type: 'string', description: 'queued | resolving | finished | failed' }, + is_complete: { type: 'boolean', description: 'Whether the reveal has completed' }, + name: { type: 'string', description: 'Full name', optional: true }, + company: { type: 'string', description: 'Company name', optional: true }, + enrichment_level: { type: 'string', description: 'Enrichment level used', optional: true }, + linkedin_profile_url: { type: 'string', description: 'LinkedIn URL', optional: true }, + title: { type: 'string', description: 'Job title', optional: true }, + location: { type: 'string', description: 'Location', optional: true }, + email: { type: 'string', description: 'Primary email', optional: true }, + email_type: { type: 'string', description: 'Email type', optional: true }, + email_status: { type: 'string', description: 'valid | risky | unfound', optional: true }, + emails: { + type: 'array', + description: 'All emails found', + optional: true, + items: { + type: 'object', + properties: { + email: { type: 'string' }, + email_type: { type: 'string' }, + email_status: { type: 'string' }, + }, + }, + }, + mobile_phone: { type: 'string', description: 'Mobile phone', optional: true }, + phone_number: { type: 'string', description: 'Direct/office phone', optional: true }, + phone_status: { type: 'string', description: 'found | unfound', optional: true }, + phones: { + type: 'array', + description: 'All phones found', + optional: true, + items: { + type: 'object', + properties: { + number: { type: 'string' }, + pretty_number: { type: 'string' }, + type: { type: 'string' }, + }, + }, + }, + company_size: { type: 'number', description: 'Employee count', optional: true }, + company_size_range: { type: 'string', description: 'Headcount range', optional: true }, + company_type: { type: 'string', description: 'Company type', optional: true }, + company_domain: { type: 'string', description: 'Company domain', optional: true }, + company_locality: { type: 'string', description: 'City', optional: true }, + company_region: { type: 'string', description: 'State/region', optional: true }, + company_country: { type: 'string', description: 'Country', optional: true }, + company_street: { type: 'string', description: 'Street', optional: true }, + company_postal_code: { type: 'string', description: 'Postal code', optional: true }, + company_founded: { type: 'number', description: 'Year founded', optional: true }, + company_funding: { type: 'string', description: 'Funding total', optional: true }, + company_revenue: { type: 'string', description: 'Revenue', optional: true }, + company_industry: { type: 'string', description: 'Industry', optional: true }, + company_subindustry: { type: 'string', description: 'Subindustry', optional: true }, + company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true }, + company_location: { type: 'string', description: 'Full company location', optional: true }, + company_description: { type: 'string', description: 'Company description', optional: true }, + credits: { type: 'json', description: 'Credits consumed by the reveal', optional: true }, + }, +} diff --git a/apps/sim/tools/wiza/prospect_search.ts b/apps/sim/tools/wiza/prospect_search.ts index c507f0cbb6..fb460b39b6 100644 --- a/apps/sim/tools/wiza/prospect_search.ts +++ b/apps/sim/tools/wiza/prospect_search.ts @@ -1,4 +1,5 @@ import type { ToolConfig } from '@/tools/types' +import { wizaHosting } from '@/tools/wiza/hosting' import type { WizaProspectSearchParams, WizaProspectSearchResponse } from '@/tools/wiza/types' export const wizaProspectSearchTool: ToolConfig< @@ -10,6 +11,12 @@ export const wizaProspectSearchTool: ToolConfig< description: "Search Wiza's database of prospects using person, company, and financial filters", version: '1.0.0', + hosting: wizaHosting(() => { + // Prospect search returns profiles without contact data and consumes no credits; + // Wiza charges only on reveal/enrichment. + return 0 + }), + params: { apiKey: { type: 'string', diff --git a/apps/sim/tools/wiza/start_individual_reveal.ts b/apps/sim/tools/wiza/start_individual_reveal.ts deleted file mode 100644 index 902c51f872..0000000000 --- a/apps/sim/tools/wiza/start_individual_reveal.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { ToolConfig } from '@/tools/types' -import type { - WizaStartIndividualRevealParams, - WizaStartIndividualRevealResponse, -} from '@/tools/wiza/types' - -export const wizaStartIndividualRevealTool: ToolConfig< - WizaStartIndividualRevealParams, - WizaStartIndividualRevealResponse -> = { - id: 'wiza_start_individual_reveal', - name: 'Wiza Start Individual Reveal', - description: - 'Start an individual reveal to enrich a contact via LinkedIn URL, name+company, or email', - version: '1.0.0', - - params: { - apiKey: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'Wiza API key', - }, - enrichment_level: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Enrichment depth: none, partial, phone, or full', - }, - profile_url: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'LinkedIn profile URL (e.g., https://linkedin.com/in/johndoe)', - }, - full_name: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Full name (used with company or domain)', - }, - company: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Company name (used with full_name)', - }, - domain: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Company domain (used with full_name)', - }, - email: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Email address (use alone or with other identifiers)', - }, - accept_work: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to accept work emails (email_options)', - }, - accept_personal: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to accept personal emails (email_options)', - }, - callback_url: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Optional URL to receive a callback with the reveal update', - }, - }, - - request: { - url: 'https://wiza.co/api/individual_reveals', - method: 'POST', - headers: (params: WizaStartIndividualRevealParams) => ({ - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), - body: (params: WizaStartIndividualRevealParams) => { - const individual: Record = {} - if (params.profile_url) individual.profile_url = params.profile_url - if (params.full_name) individual.full_name = params.full_name - if (params.company) individual.company = params.company - if (params.domain) individual.domain = params.domain - if (params.email) individual.email = params.email - - const body: Record = { - individual_reveal: individual, - enrichment_level: params.enrichment_level, - } - - if (params.accept_work !== undefined || params.accept_personal !== undefined) { - const emailOptions: Record = {} - if (params.accept_work !== undefined) emailOptions.accept_work = params.accept_work - if (params.accept_personal !== undefined) { - emailOptions.accept_personal = params.accept_personal - } - body.email_options = emailOptions - } - - if (params.callback_url) body.callback_url = params.callback_url - - return body - }, - }, - - transformResponse: async (response: Response) => { - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Wiza API error: ${response.status} - ${errorText}`) - } - - const json = await response.json() - const d = json.data ?? {} - - return { - success: true, - output: { - id: d.id ?? null, - status: d.status ?? null, - is_complete: d.is_complete ?? null, - }, - } - }, - - outputs: { - id: { - type: 'number', - description: 'Individual reveal ID (use with Get Individual Reveal)', - optional: true, - }, - status: { - type: 'string', - description: 'Reveal status: queued, resolving, finished, or failed', - optional: true, - }, - is_complete: { - type: 'boolean', - description: 'Whether the reveal has completed', - optional: true, - }, - }, -} diff --git a/apps/sim/tools/wiza/types.ts b/apps/sim/tools/wiza/types.ts index 00db0a2356..603a3fa933 100644 --- a/apps/sim/tools/wiza/types.ts +++ b/apps/sim/tools/wiza/types.ts @@ -105,7 +105,7 @@ export interface WizaCompanyEnrichmentResponse extends ToolResponse { } } -export interface WizaStartIndividualRevealParams { +export interface WizaIndividualRevealParams { apiKey: string enrichment_level: 'none' | 'partial' | 'phone' | 'full' profile_url?: string @@ -115,10 +115,9 @@ export interface WizaStartIndividualRevealParams { email?: string accept_work?: boolean accept_personal?: boolean - callback_url?: string } -interface WizaIndividualRevealData { +export interface WizaIndividualRevealData { id: number | null status: string | null is_complete: boolean | null @@ -164,20 +163,7 @@ interface WizaIndividualRevealData { credits: Record | null } -export interface WizaStartIndividualRevealResponse extends ToolResponse { - output: { - id: number | null - status: string | null - is_complete: boolean | null - } -} - -export interface WizaGetIndividualRevealParams { - apiKey: string - id: string -} - -export interface WizaGetIndividualRevealResponse extends ToolResponse { +export interface WizaIndividualRevealResponse extends ToolResponse { output: WizaIndividualRevealData } @@ -185,5 +171,4 @@ export type WizaResponse = | WizaGetCreditsResponse | WizaProspectSearchResponse | WizaCompanyEnrichmentResponse - | WizaStartIndividualRevealResponse - | WizaGetIndividualRevealResponse + | WizaIndividualRevealResponse