Skip to content

Commit df8fe6e

Browse files
feat(enrichments): bill hosted-key cost; surface provider errors; abort safety
- runEnrichment now returns { result, cost, error }: accumulates hosted-key cost across the cascade, and sets `error` only when every provider that ran errored (auth/rate-limit/outage) vs a clean miss. - Executor records the cost to the table owner (createdBy) via recordUsage (source 'enrichment'); billing failures are logged, never error the cell. - F1: all-providers-errored now writes status 'error' instead of a blank 'completed' cell that looked like "no data found". - F2: re-check the abort signal after the cascade so a cancel mid-tool-call isn't recorded as a completed empty cell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ab9a4c commit df8fe6e

2 files changed

Lines changed: 86 additions & 6 deletions

File tree

apps/sim/background/workflow-column-execution.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,57 @@ async function runWorkflowAndWriteTerminal(
174174

175175
try {
176176
if (signal?.aborted) return 'error'
177-
const result = await runEnrichment(enrichment, enrichInputs, {
177+
const { result, cost, error } = await runEnrichment(enrichment, enrichInputs, {
178178
tableId,
179179
rowId,
180180
workspaceId,
181181
signal,
182182
})
183+
184+
// An abort during the cascade must not be recorded as a completed cell.
185+
if (signal?.aborted) return 'error'
186+
187+
// Every provider that ran errored (auth / rate-limit / outage) — surface
188+
// it rather than writing a blank cell that looks like "no data found".
189+
if (error) {
190+
await writeState({
191+
status: 'error',
192+
executionId,
193+
jobId: null,
194+
workflowId: statusId,
195+
error,
196+
})
197+
return 'error'
198+
}
199+
200+
// Bill the table owner for any hosted-key cost the providers incurred.
201+
// Billing failures must not error an otherwise-successful cell.
202+
if (cost > 0 && table.createdBy) {
203+
try {
204+
const { recordUsage } = await import('@/lib/billing/core/usage-log')
205+
await recordUsage({
206+
userId: table.createdBy,
207+
workspaceId,
208+
executionId,
209+
entries: [
210+
{
211+
category: 'fixed',
212+
source: 'enrichment',
213+
description: enrichment.name,
214+
cost,
215+
metadata: { enrichmentId: enrichment.id, tableId, rowId },
216+
},
217+
],
218+
})
219+
} catch (billingErr) {
220+
logger.error('Failed to record enrichment usage', {
221+
enrichmentId: enrichment.id,
222+
cost,
223+
error: toError(billingErr).message,
224+
})
225+
}
226+
}
227+
183228
const dataPatch: RowData = {}
184229
for (const out of group.outputs) {
185230
if (!out.outputId) continue

apps/sim/enrichments/run.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,39 @@ import { executeTool } from '@/tools'
55

66
const logger = createLogger('Enrichments')
77

8+
/** Outcome of running an enrichment's provider cascade for one row. */
9+
export interface EnrichmentRunOutcome {
10+
/** Mapped output values from the winning provider, or `{}` when none hit. */
11+
result: Record<string, unknown>
12+
/** Total hosted-key cost (USD) across providers that ran; `0` for BYOK / free. */
13+
cost: number
14+
/**
15+
* Set only when every provider that actually ran errored (none produced a
16+
* clean result or a clean miss). Lets the caller mark the cell errored instead
17+
* of blanking it — a genuine "no match" still leaves this `null`.
18+
*/
19+
error: string | null
20+
}
21+
822
/** True when at least one output value in the result is non-empty. */
923
function hasResult(result: Record<string, unknown>): boolean {
1024
return Object.values(result).some((v) => v !== undefined && v !== null && v !== '')
1125
}
1226

27+
/** Reads the hosted-key cost `executeTool` merges into a successful output. */
28+
function readCost(output: Record<string, unknown>): number {
29+
const total = (output.cost as { total?: unknown } | undefined)?.total
30+
return typeof total === 'number' && Number.isFinite(total) && total > 0 ? total : 0
31+
}
32+
1333
/**
1434
* Runs an enrichment's provider cascade for one row. Tries providers in order;
1535
* the first that returns a non-empty result wins and is returned. A provider is
1636
* skipped when its `buildParams` returns `null` (insufficient inputs); a tool
1737
* failure or empty mapped result falls through to the next. When every provider
18-
* misses, returns `{}` — the caller writes a blank (not errored) cell.
38+
* that ran errored, `error` is set so the caller can mark the cell errored; a
39+
* clean miss leaves `error: null` (blank cell). Hosted-key cost is accumulated
40+
* across providers for the caller to bill.
1941
*
2042
* Server-only: imports `executeTool`, which pulls in DB / mailer code. Only the
2143
* background cell executor imports this module (dynamically).
@@ -24,11 +46,17 @@ export async function runEnrichment(
2446
enrichment: EnrichmentConfig,
2547
inputs: Record<string, unknown>,
2648
ctx: EnrichmentRunContext
27-
): Promise<Record<string, unknown>> {
49+
): Promise<EnrichmentRunOutcome> {
50+
let cost = 0
51+
let ranCount = 0
52+
let errorCount = 0
53+
let lastError: string | null = null
54+
2855
for (const provider of enrichment.providers) {
2956
if (ctx.signal?.aborted) break
3057
const params = provider.buildParams(inputs)
3158
if (!params) continue
59+
ranCount++
3260
try {
3361
const response = await executeTool(
3462
provider.toolId,
@@ -38,18 +66,25 @@ export async function runEnrichment(
3866
if (!response.success) {
3967
throw new Error(response.error ?? `${provider.toolId} failed`)
4068
}
69+
cost += readCost(response.output)
4170
const result = provider.mapOutput(response.output)
4271
if (result && hasResult(result)) {
4372
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
44-
return result
73+
return { result, cost, error: null }
4574
}
4675
} catch (err) {
76+
errorCount++
77+
lastError = getErrorMessage(err)
4778
logger.warn('Enrichment provider failed; trying next', {
4879
enrichmentId: enrichment.id,
4980
provider: provider.id,
50-
error: getErrorMessage(err),
81+
error: lastError,
5182
})
5283
}
5384
}
54-
return {}
85+
86+
// No provider hit. Surface an error only when every provider that ran errored
87+
// (infra/auth/rate-limit) — a clean miss returns a blank result instead.
88+
const error = ranCount > 0 && errorCount === ranCount ? lastError : null
89+
return { result: {}, cost, error }
5590
}

0 commit comments

Comments
 (0)