mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza (#4777)
* feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza Add hosted-key support across all credit-consuming Findymail, Prospeo, and Wiza operations so Sim provides the key when a workspace has not brought its own. Register the three BYOK providers, consolidate Wiza's two-step reveal into a single polling wiza_individual_reveal op, and hide the API key field on hosted Sim for hosted operations. * fix(integrations): harden Wiza reveal polling, soften enrichment getCost guards Address Greptile + Cursor Bugbot review on #4777: return explicit failures from the Wiza individual_reveal poller instead of throwing (thrown errors were swallowed into a false queued success), short-circuit when the initial reveal is already terminal, tolerate transient 5xx/429 during polling, and return 0 (not throw) from Findymail getCost when the contacts/employees array is absent. * chore(integrations): biome formatting after wiza merge resolution * fix(wiza): type isTerminalReveal param structurally for next build typecheck * feat(enrichments): add Findymail, Prospeo, Wiza to work-email waterfall * feat(enrichments): add Wiza + Prospeo phone reveal to phone-number waterfall * feat(enrichments): opportunistic identifiers + LinkedIn URL input across work-email & phone cascades
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -214,7 +214,7 @@ export const FindymailBlock: BlockConfig<FindymailResponse> = {
|
||||
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<FindymailResponse> = {
|
||||
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: {
|
||||
|
||||
@@ -302,7 +302,7 @@ export const ProspeoBlock: BlockConfig<ProspeoResponse> = {
|
||||
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<ProspeoResponse> = {
|
||||
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: {
|
||||
|
||||
@@ -24,8 +24,7 @@ export const WizaBlock: BlockConfig<WizaResponse> = {
|
||||
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<WizaResponse> = {
|
||||
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' },
|
||||
condition: { field: 'operation', value: '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' },
|
||||
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',
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>[])
|
||||
: []
|
||||
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<string, unknown> | undefined
|
||||
const mobile = person?.mobile as Record<string, unknown> | undefined
|
||||
const phone = str(mobile?.mobile)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined
|
||||
const emailObj = person?.email as Record<string, unknown> | 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',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const byokProviderIdSchema = z.enum([
|
||||
'cohere',
|
||||
'hunter',
|
||||
'peopledatalabs',
|
||||
'findymail',
|
||||
'prospeo',
|
||||
'wiza',
|
||||
])
|
||||
|
||||
export const byokKeySchema = z.object({
|
||||
|
||||
@@ -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<any, any>, params: any, output: Record<string, unknown>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<FindymailFindEmailFromLinkedInParams>((_params, output) => {
|
||||
const contact = output.contact as { email?: string } | null
|
||||
return contact?.email ? 1 : 0
|
||||
}),
|
||||
|
||||
params: {
|
||||
linkedin_url: {
|
||||
type: 'string',
|
||||
|
||||
@@ -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<FindymailFindEmailFromNameParams>((_params, output) => {
|
||||
const contact = output.contact as { email?: string } | null
|
||||
return contact?.email ? 1 : 0
|
||||
}),
|
||||
|
||||
params: {
|
||||
name: {
|
||||
type: 'string',
|
||||
|
||||
@@ -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<FindymailFindEmailsByDomainParams>((_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',
|
||||
|
||||
@@ -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<FindymailFindEmployeesParams>((_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',
|
||||
|
||||
@@ -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<FindymailFindPhoneParams, FindymailFindPh
|
||||
"Find someone's phone number from a LinkedIn profile URL. Uses 10 finder credits if a phone is found. EU citizens are excluded for legal reasons.",
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: findymailHosting<FindymailFindPhoneParams>((_params, output) => {
|
||||
// Phone lookups consume 10 finder credits, only when a number is found.
|
||||
return output.phone ? 10 : 0
|
||||
}),
|
||||
|
||||
params: {
|
||||
linkedin_url: {
|
||||
type: 'string',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findymailHosting } from '@/tools/findymail/hosting'
|
||||
import type {
|
||||
FindymailGetCompanyParams,
|
||||
FindymailGetCompanyResponse,
|
||||
@@ -11,6 +12,11 @@ export const getCompanyTool: ToolConfig<FindymailGetCompanyParams, FindymailGetC
|
||||
'Retrieve company information from a LinkedIn URL, domain, or company name. Uses 1 finder credit per successful response.',
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: findymailHosting<FindymailGetCompanyParams>((_params, output) => {
|
||||
// 1 finder credit per successful company match.
|
||||
return output.name || output.domain ? 1 : 0
|
||||
}),
|
||||
|
||||
params: {
|
||||
linkedin_url: {
|
||||
type: 'string',
|
||||
|
||||
@@ -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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<FindymailLookupTechnologiesParams>((_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',
|
||||
|
||||
@@ -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<FindymailReverseEmailLookupParams>((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',
|
||||
|
||||
@@ -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<FindymailSearchTechnologiesParams>(() => {
|
||||
// Free catalog search — consumes no Findymail credits.
|
||||
return 0
|
||||
}),
|
||||
|
||||
params: {
|
||||
q: {
|
||||
type: 'string',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findymailHosting } from '@/tools/findymail/hosting'
|
||||
import type {
|
||||
FindymailVerifyEmailParams,
|
||||
FindymailVerifyEmailResponse,
|
||||
@@ -11,6 +12,11 @@ export const verifyEmailTool: ToolConfig<FindymailVerifyEmailParams, FindymailVe
|
||||
description: 'Verifies the deliverability of an email address. Uses one verifier credit.',
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: findymailHosting<FindymailVerifyEmailParams>(() => {
|
||||
// Each verification consumes one verifier credit, billed at the finder-credit rate.
|
||||
return 1
|
||||
}),
|
||||
|
||||
params: {
|
||||
email: {
|
||||
type: 'string',
|
||||
|
||||
@@ -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<ProspeoBulkEnrichCompanyParams>((_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',
|
||||
|
||||
@@ -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<ProspeoBulkEnrichPersonParams>((_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',
|
||||
|
||||
@@ -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<ProspeoEnrichCompanyParams>((_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',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prospeoHosting } from '@/tools/prospeo/hosting'
|
||||
import {
|
||||
extractProspeoError,
|
||||
type ProspeoEnrichPersonParams,
|
||||
@@ -12,6 +13,16 @@ export const enrichPersonTool: ToolConfig<ProspeoEnrichPersonParams, ProspeoEnri
|
||||
description: 'Enrich a person with complete B2B profile data, email address and mobile.',
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: prospeoHosting<ProspeoEnrichPersonParams>((_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<string, unknown> | 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',
|
||||
|
||||
@@ -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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<ProspeoSearchCompanyParams>((_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',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prospeoHosting } from '@/tools/prospeo/hosting'
|
||||
import {
|
||||
extractProspeoError,
|
||||
PROSPEO_PAGINATION_OUTPUT,
|
||||
@@ -14,6 +15,13 @@ export const searchPersonTool: ToolConfig<ProspeoSearchPersonParams, ProspeoSear
|
||||
description: 'Search for leads using 20+ filters to build targeted contact lists.',
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: prospeoHosting<ProspeoSearchPersonParams>((_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',
|
||||
|
||||
@@ -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<string, ToolConfig> = {
|
||||
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,
|
||||
|
||||
@@ -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<P = Record<string, unknown>> = PerRequestPricing
|
||||
* Adding more keys only requires updating the count and adding the new env var —
|
||||
* no code changes needed.
|
||||
*/
|
||||
interface ToolHostingConfig<P = Record<string, unknown>> {
|
||||
export interface ToolHostingConfig<P = Record<string, unknown>> {
|
||||
/** Optional predicate for tools where hosted keys only apply to some parameter combinations. */
|
||||
enabled?: (params: P) => boolean
|
||||
/**
|
||||
|
||||
@@ -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<WizaCompanyEnrichmentParams>((_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',
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<P>(
|
||||
getCredits: (params: P, output: Record<string, unknown>) => number
|
||||
): ToolHostingConfig<P> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown>): WizaIndividualRevealData {
|
||||
const emails = Array.isArray(d.emails) ? (d.emails as Record<string, unknown>[]) : []
|
||||
const phones = Array.isArray(d.phones) ? (d.phones as Record<string, unknown>[]) : []
|
||||
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<string, unknown>) ?? 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<WizaIndividualRevealParams>((_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<string, unknown> = {}
|
||||
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<string, unknown> = {
|
||||
individual_reveal: individual,
|
||||
enrichment_level: params.enrichment_level,
|
||||
}
|
||||
|
||||
if (params.accept_work !== undefined || params.accept_personal !== undefined) {
|
||||
const emailOptions: Record<string, unknown> = {}
|
||||
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 },
|
||||
},
|
||||
}
|
||||
@@ -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<WizaProspectSearchParams>(() => {
|
||||
// Prospect search returns profiles without contact data and consumes no credits;
|
||||
// Wiza charges only on reveal/enrichment.
|
||||
return 0
|
||||
}),
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
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<string, unknown> = {
|
||||
individual_reveal: individual,
|
||||
enrichment_level: params.enrichment_level,
|
||||
}
|
||||
|
||||
if (params.accept_work !== undefined || params.accept_personal !== undefined) {
|
||||
const emailOptions: Record<string, unknown> = {}
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<string, unknown> | 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
|
||||
|
||||
Reference in New Issue
Block a user