Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/payments/billing-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* How Franklin describes a charge to the user and to the model.
*
* Key mode spends prepaid account credits; wallet mode signs USDC over x402.
* Saying "USDC charged" or "paid via x402" in key mode is not a cosmetic slip:
* these strings are the receipt the user reads, the approval prompt they say
* yes to, and the text the model reasons over when it decides whether it can
* afford the next call. Naming the wrong instrument makes all three wrong.
*
* Two rules, and the split matters:
*
* Runtime output — receipts, approval prompts, cancellations — calls these
* helpers, which read the mode at call time. `invalidateKey()` can demote a
* session to wallet mode mid-run, so the mode must not be captured earlier.
*
* Static `spec.description` text is built once at module load, before any
* mode is settled and with no way to re-render afterwards. It must be
* phrased so it is true in BOTH modes — state the price, not the rail:
* "Costs $0.001 per call", never "Costs $0.001 USDC from the wallet".
* These helpers deliberately do not serve that case.
*/

import { isKeyMode } from './auth-mode.js';

function fmt(usd: number): string {
// Sub-cent prices are real here ($0.001 tiers), so keep enough places to
// avoid rendering them as $0.00.
return usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
}

/**
* Parenthetical for a completed charge, e.g. `($5.00 USDC charged)` or
* `(billed to account credits)`. Key mode states no amount: the local figure
* is an estimate and the account ledger is authoritative.
*/
export function chargedNote(usd: number): string {
return isKeyMode()
? 'billed to account credits — see Activity at user.blockrun.ai'
: `${fmt(usd)} USDC charged`;
}

/** Sentence for an action that was cancelled or never dispatched. */
export function noChargeNote(): string {
return isKeyMode() ? 'No account credits were spent.' : 'No USDC was spent.';
}

/** Trailing reassurance on an approval prompt, before the user answers. */
export function cancelHint(): string {
return isKeyMode()
? 'No account credits are spent if you cancel.'
: 'No USDC is spent if you cancel.';
}

/**
* Italic receipt line appended to a markdown tool result, e.g.
* `_$0.005 paid via x402._`. In key mode the rail is wrong AND the amount is
* unverifiable locally, so it names neither.
*/
export function receiptLine(usd: number): string {
return isKeyMode()
? '_Billed to account credits._'
: `_${fmt(usd)} paid via x402._`;
}
2 changes: 1 addition & 1 deletion src/tools/blockrun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export const blockrunCapability: CapabilityHandler = {
spec: {
name: 'BlockRun',
description:
'Call any BlockRun gateway endpoint. Signs an x402 USDC payment from the user wallet, retries on HTTP 402, and returns the response. ' +
'Call any BlockRun gateway endpoint. Billed to account credits or, in wallet mode, by signing an x402 USDC payment and retrying on HTTP 402. ' +
'Use this for crypto data (Surf — markets, on-chain, social), AI inference (chat / image / video / music), prediction markets, DeFi data, and any other API exposed under https://blockrun.ai/marketplace. ' +
'For phone and voice, prefer the typed tools (ListPhoneNumbers, BuyPhoneNumber, RenewPhoneNumber, ReleasePhoneNumber, PhoneLookup, PhoneFraudCheck, VoiceCall, VoiceStatus) — they spell out cost, required fields, and the buy-number-first requirement. ' +
'The path must start with "/v1/" or "/.well-known/". ' +
Expand Down
9 changes: 5 additions & 4 deletions src/tools/imagegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { findModel, estimateCostUsd, GATEWAY_MARGIN, type GatewayModel } from '.
import { logger } from '../logger.js';
import { ssrfSafeFetch } from './ssrf.js';
import { isWalletKeyPath } from './sensitive-paths.js';
import { noChargeNote, cancelHint } from '../payments/billing-copy.js';

interface ImageGenInput {
prompt: string;
Expand Down Expand Up @@ -352,7 +353,7 @@ function buildExecute(deps: ImageGenDeps) {
output:
`## Image generation skipped\n` +
`- ${decision.reason}\n\n` +
`No USDC was spent. Choose a cheaper model/size or raise the ` +
`${noChargeNote()} Choose a cheaper model/size or raise the ` +
`content budget before trying again.`,
};
}
Expand All @@ -368,11 +369,11 @@ function buildExecute(deps: ImageGenDeps) {
const priceNote = est > 0 ? ` for ~$${est.toFixed(2)}` : '';
const countNote = n > 1 ? `${n} images` : 'an image';
const answer = await ctx.onAskUser(
`Generate ${countNote} with ${imageModel}${priceNote}? No USDC is spent if you cancel.`,
`Generate ${countNote} with ${imageModel}${priceNote}? ${cancelHint()}`,
['Generate', 'Cancel'],
);
if (answer !== 'Generate') {
return { output: `## Image generation cancelled\n\nNo USDC was spent.` };
return { output: `## Image generation cancelled\n\n${noChargeNote()}` };
}
}

Expand Down Expand Up @@ -805,7 +806,7 @@ export function createImageGenCapability(deps: ImageGenDeps = {}): CapabilityHan
"Generate or edit an image. Text-to-image from a prompt, or " +
"image-to-image when you pass a reference image (style transfer, " +
"character consistency, edits). Supports mask-based inpainting and " +
"multi-image fusion. Costs USDC from the user's wallet — confirm " +
"multi-image fusion. Billed per image — confirm " +
"before generating. Saves to local file(s). Default size: 1024x1024. " +
"Do NOT call repeatedly to iterate on style — ask the user first. " +
"Pass contentId to attach the result to an existing Content piece: " +
Expand Down
3 changes: 2 additions & 1 deletion src/tools/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { gatewayBase, gatewayHeaders, isKeyMode } from '../payments/auth-mode.js
import { walletReservation, AMBIGUOUS_GRACE_MS, type ReservationToken } from '../wallet/reservation.js';
import { recordUsage } from '../stats/tracker.js';
import { logger } from '../logger.js';
import { noChargeNote } from '../payments/billing-copy.js';

// ─── Pricing table (probed from /.well-known/x402 + 402 responses) ─────────
const CREATE_PRICE_USD: Record<string, number> = {
Expand Down Expand Up @@ -456,7 +457,7 @@ export const modalCreateCapability: CapabilityHandler = {
try {
const answer = await ctx.onAskUser(lines.join('\n'), ['Approve', 'Cancel']);
if (answer !== 'Approve') {
return { output: '## Sandbox creation cancelled\n\nNo USDC was spent.' };
return { output: `## Sandbox creation cancelled\n\n${noChargeNote()}` };
}
} catch {
// askUser failed (UI gone) — fall through and create. Better than
Expand Down
7 changes: 4 additions & 3 deletions src/tools/musicgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { ContentLibrary } from '../content/library.js';
import { isWalletKeyPath } from './sensitive-paths.js';
import { findModel, estimateCostUsd, type GatewayModel } from '../gateway-models.js';
import { recordUsage } from '../stats/tracker.js';
import { noChargeNote } from '../payments/billing-copy.js';

interface MusicGenInput {
prompt: string;
Expand Down Expand Up @@ -101,15 +102,15 @@ function buildExecute(deps: MusicGenDeps) {
if (contentId && deps.library) {
const content = deps.library.get(contentId);
if (!content) {
return { output: `Content ${contentId} not found. No USDC was spent.` };
return { output: `Content ${contentId} not found. ${noChargeNote()}` };
}
if (content.spentUsd + trackCostUsd > content.budgetUsd + 1e-9) {
return {
output:
`## Music generation skipped\n` +
`- Would exceed budget: spent $${content.spentUsd.toFixed(2)} + fixed ` +
`$${trackCostUsd.toFixed(2)} > cap $${content.budgetUsd.toFixed(2)}\n\n` +
`No USDC was spent.`,
noChargeNote(),
};
}
}
Expand Down Expand Up @@ -331,7 +332,7 @@ export function createMusicGenCapability(deps: MusicGenDeps = {}): CapabilityHan
description:
"Generate a ~3-minute MP3 track from a text prompt (plus optional " +
"lyrics or instrumental flag). Calls BlockRun's /v1/audio/generations. " +
"Costs $0.1575 USDC per call — bills a flat rate, MiniMax ignores " +
"Costs $0.1575 per call — bills a flat rate, MiniMax ignores " +
"duration hints and always returns ~3 min. Generation takes 1–3 " +
"minutes. ALWAYS confirm with the user before calling — music is " +
"expensive and slow. Pass contentId to attach to a Content piece " +
Expand Down
31 changes: 16 additions & 15 deletions src/tools/phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { loadChain, VERSION} from '../config.js';
import { gatewayBase, gatewayHeaders } from '../payments/auth-mode.js';
import { logger } from '../logger.js';
import { recordUsage } from '../stats/tracker.js';
import { chargedNote, noChargeNote, cancelHint } from '../payments/billing-copy.js';

const PHONE_TIMEOUT_MS = 30_000;

Expand Down Expand Up @@ -173,7 +174,7 @@ export const listPhoneNumbersCapability: CapabilityHandler = {
description:
'List the phone numbers your wallet currently owns (US/CA, leased 30 days at a time). ' +
'Use this before any phone-related action to remind the agent what numbers are available. ' +
'Costs $0.001 USDC. Returns each number with country, area code, expiration timestamp, ' +
'Costs $0.001 per call. Returns each number with country, area code, expiration timestamp, ' +
'and current status (active/expiring/expired).',
input_schema: { type: 'object', properties: {} },
},
Expand All @@ -184,7 +185,7 @@ export const listPhoneNumbersCapability: CapabilityHandler = {
);
return {
output:
`## Phone numbers (wallet-owned)\n\n` +
`## Phone numbers (provisioned to this account)\n\n` +
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
};
} catch (err) {
Expand All @@ -197,7 +198,7 @@ export const buyPhoneNumberCapability: CapabilityHandler = {
spec: {
name: 'BuyPhoneNumber',
description:
'Provision a new US or CA phone number for the wallet for 30 days. Costs $5 USDC. ' +
'Provision a new US or CA phone number for 30 days. Costs $5.00 per number. ' +
'Optionally pin a 3-digit area code (best effort). The provisioned number is auto-registered ' +
'as a valid caller ID for outbound VoiceCall. A wallet can hold multiple numbers; this adds ' +
'one, never replaces. To pick the country: country="US" (default) or country="CA".',
Expand All @@ -219,11 +220,11 @@ export const buyPhoneNumberCapability: CapabilityHandler = {
const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
if (!autoApprove && ctx.onAskUser) {
const answer = await ctx.onAskUser(
`Buy a new ${body.country || 'US'} phone number for $5.00 USDC? No USDC is spent if you cancel.`,
`Buy a new ${body.country || 'US'} phone number for $5.00? ${cancelHint()}`,
['Buy', 'Cancel'],
);
if (answer !== 'Buy') {
return { output: `## Phone number purchase cancelled\n\nNo USDC was spent.` };
return { output: `## Phone number purchase cancelled\n\n${noChargeNote()}` };
}
}
try {
Expand All @@ -232,7 +233,7 @@ export const buyPhoneNumberCapability: CapabilityHandler = {
);
return {
output:
`## Number provisioned ($5 USDC charged)\n\n` +
`## Number provisioned (${chargedNote(5)})\n\n` +
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
};
} catch (err) {
Expand All @@ -245,7 +246,7 @@ export const renewPhoneNumberCapability: CapabilityHandler = {
spec: {
name: 'RenewPhoneNumber',
description:
'Extend the 30-day lease on a wallet-owned phone number. Costs $5 USDC. Use ListPhoneNumbers ' +
'Extend the 30-day lease on a BlockRun-provisioned phone number. Costs $5.00. Use ListPhoneNumbers ' +
'first to confirm the number is yours. Released or expired numbers cannot be renewed — buy a ' +
'new one with BuyPhoneNumber instead.',
input_schema: {
Expand All @@ -264,11 +265,11 @@ export const renewPhoneNumberCapability: CapabilityHandler = {
const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
if (!autoApprove && ctx.onAskUser) {
const answer = await ctx.onAskUser(
`Renew ${input.phone_number} for 30 days at $5.00 USDC? No USDC is spent if you cancel.`,
`Renew ${input.phone_number} for 30 days at $5.00? ${cancelHint()}`,
['Renew', 'Cancel'],
);
if (answer !== 'Renew') {
return { output: `## Renewal cancelled\n\nNo USDC was spent.` };
return { output: `## Renewal cancelled\n\n${noChargeNote()}` };
}
}
try {
Expand All @@ -280,7 +281,7 @@ export const renewPhoneNumberCapability: CapabilityHandler = {
);
return {
output:
`## Lease renewed (+30 days, $5 USDC charged)\n\n` +
`## Lease renewed (+30 days, ${chargedNote(5)})\n\n` +
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
};
} catch (err) {
Expand All @@ -293,7 +294,7 @@ export const releasePhoneNumberCapability: CapabilityHandler = {
spec: {
name: 'ReleasePhoneNumber',
description:
'Release a wallet-owned phone number back to the BlockRun pool before its lease expires. ' +
'Release a BlockRun-provisioned phone number back to the pool before its lease expires. ' +
'Free. The number is gone after this — it may be picked up by another wallet. Use when you ' +
"no longer need a test number and want it out of your ListPhoneNumbers result.",
input_schema: {
Expand Down Expand Up @@ -331,8 +332,8 @@ export const phoneLookupCapability: CapabilityHandler = {
name: 'PhoneLookup',
description:
'Look up carrier and line type information for ANY phone number (does not need to be ' +
'wallet-owned). Returns carrier name, line type (mobile/landline/voip), country, and ' +
'portability info. Costs $0.01 USDC. Use to validate a number before texting/calling or ' +
'BlockRun-provisioned). Returns carrier name, line type (mobile/landline/voip), country, and ' +
'portability info. Costs $0.01 per lookup. Use to validate a number before texting/calling or ' +
'to figure out whether a contact number is a real mobile.',
input_schema: {
type: 'object',
Expand All @@ -355,7 +356,7 @@ export const phoneLookupCapability: CapabilityHandler = {
);
return {
output:
`## Phone lookup ($0.01 USDC charged)\n\n` +
`## Phone lookup (${chargedNote(0.01)})\n\n` +
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
};
} catch (err) {
Expand Down Expand Up @@ -392,7 +393,7 @@ export const phoneFraudCheckCapability: CapabilityHandler = {
);
return {
output:
`## Fraud check ($0.05 USDC charged)\n\n` +
`## Fraud check (${chargedNote(0.05)})\n\n` +
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
};
} catch (err) {
Expand Down
19 changes: 10 additions & 9 deletions src/tools/prediction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { logger } from '../logger.js';
import { recordFetch } from '../trading/providers/telemetry.js';
import { recordUsage } from '../stats/tracker.js';
import { frameUntrusted } from './untrusted.js';
import { receiptLine } from '../payments/billing-copy.js';

const TIMEOUT_MS = 30_000;
const DEFAULT_LIMIT = 20;
Expand Down Expand Up @@ -527,7 +528,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
lines.push(`${i + 1}. **[${platform}]** ${title}`);
});
}
lines.push(`_$0.005 paid via x402._`);
lines.push(receiptLine(0.005));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -573,7 +574,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
if (winRate != null) parts.push(`win ${formatPct(winRate as number, 0)}`);
lines.push(`${i + 1}. \`${w}\`${handle}` + (parts.length > 0 ? ` — ${parts.join(' · ')}` : ''));
});
lines.push('', `_$0.001 paid via x402._`);
lines.push('', receiptLine(0.001));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -684,7 +685,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
}
if (recent.length > 0) lines.push(` ${recent.join(' · ')}`);
});
lines.push('', `_$0.005 paid via x402._`);
lines.push('', receiptLine(0.005));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -760,7 +761,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
});
}
}
lines.push('', `_$0.005 paid via x402._`);
lines.push('', receiptLine(0.005));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -829,7 +830,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
}
lines.push(`${i + 1}. **${title}** — ${parts.join(' · ')}`);
});
lines.push('', `_$0.005 paid via x402._`);
lines.push('', receiptLine(0.005));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -868,7 +869,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
if (netBuyersPct != null) stats.push(`${formatPct(netBuyersPct as number, 0)} net buyers`);
lines.push(`${i + 1}. **${title}**${cidTag}` + (stats.length > 0 ? `\n ${stats.join(' · ')}` : ''));
});
lines.push('', `_$0.005 paid via x402._`);
lines.push('', receiptLine(0.005));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -912,7 +913,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
if (pos.avg_smart_win_rate != null) perf.push(`win ${formatPct(pos.avg_smart_win_rate, 0)}`);
lines.push(`**Smart performance:** ${perf.join(' · ')}`);
}
lines.push('', `_$0.005 paid via x402._`);
lines.push('', receiptLine(0.005));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -960,7 +961,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
(end ? ` · ends ${String(end).slice(0, 10)}` : '')
);
});
lines.push('', `_$0.001 paid via x402._`);
lines.push('', receiptLine(0.001));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down Expand Up @@ -998,7 +999,7 @@ async function execute(input: Record<string, unknown>, ctx: ExecutionScope): Pro
(m.close_time ? ` · closes ${String(m.close_time).slice(0, 10)}` : '')
);
});
lines.push('', `_$0.001 paid via x402._`);
lines.push('', receiptLine(0.001));
return { output: frameUntrusted('Prediction-market data (untrusted)', lines.join('\n')) };
}

Expand Down
Loading
Loading