Skip to content

Commit fa6dc88

Browse files
committed
feat: simplify tiered pricing to show only user's plan tier price (#578)
Instead of outputting verbose pricing breakdowns for all tiers (BRONZE, SILVER, GOLD, PLATINUM, DIAMOND), resolve to a single price based on the user's actual plan tier. Adds a short hint like "(your BRONZE plan price)" in text output and a `resolvedForTier` field in structured output. Changes: - Add getUserPlanTierCached() in userid_cache.ts (separate cache) - Add tier resolution helpers in pricing_info.ts - Thread userTier through actor_card.ts and actor_details.ts format functions - Update all 5 tool handlers to fetch and pass user tier - Remove tieredPricing arrays from structured output schemas - Add unit tests for pricing tier resolution https://claude.ai/code/session_01RNnzsiNrtM3qJX9i1Kqfqy
1 parent 008951a commit fa6dc88

File tree

11 files changed

+373
-103
lines changed

11 files changed

+373
-103
lines changed

src/tools/default/fetch_actor_details.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolveOutputOptions,
99
} from '../../utils/actor_details.js';
1010
import { buildMCPResponse } from '../../utils/mcp.js';
11+
import { getUserPlanTierCached } from '../../utils/userid_cache.js';
1112
import {
1213
fetchActorDetailsMetadata,
1314
fetchActorDetailsToolArgsSchema,
@@ -27,7 +28,8 @@ export const defaultFetchActorDetails: ToolEntry = Object.freeze({
2728
const resolvedOutput = resolveOutputOptions(parsed.output);
2829
const cardOptions = buildCardOptions(resolvedOutput);
2930

30-
const details = await fetchActorDetails(apifyClient, parsed.actor, cardOptions);
31+
const userTier = await getUserPlanTierCached(apifyToken, apifyClient);
32+
const details = await fetchActorDetails(apifyClient, parsed.actor, cardOptions, userTier);
3133
if (!details) {
3234
return buildActorNotFoundResponse(parsed.actor);
3335
}

src/tools/default/search_actors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { ApifyClient } from '../../apify_client.js';
12
import { HelperTools } from '../../const.js';
23
import type { InternalToolArgs, ToolEntry } from '../../types.js';
34
import { formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
45
import { searchAndFilterActors } from '../../utils/actor_search.js';
56
import { buildMCPResponse } from '../../utils/mcp.js';
7+
import { getUserPlanTierCached } from '../../utils/userid_cache.js';
68
import {
79
searchActorsArgsSchema,
810
searchActorsMetadata,
@@ -38,7 +40,9 @@ You MUST retry with broader, more generic keywords - use just the platform name
3840
return buildMCPResponse({ texts: [instructions], structuredContent });
3941
}
4042

41-
const structuredActorCards = actors.map((actor) => formatActorToStructuredCard(actor));
43+
const userTier = await getUserPlanTierCached(apifyToken, new ApifyClient({ token: apifyToken ?? undefined }));
44+
45+
const structuredActorCards = actors.map((actor) => formatActorToStructuredCard(actor, undefined, userTier));
4246
const structuredContent = {
4347
actors: structuredActorCards,
4448
query: parsed.keywords,
@@ -47,7 +51,7 @@ You MUST retry with broader, more generic keywords - use just the platform name
4751
IMPORTANT: You MUST always do a second search with broader, more generic keywords (e.g., just the platform name like "TikTok" instead of "TikTok posts") to make sure you haven't missed a better Actor.`,
4852
};
4953

50-
const actorCards = actors.map((actor) => formatActorToActorCard(actor));
54+
const actorCards = actors.map((actor) => formatActorToActorCard(actor, undefined, userTier));
5155
const actorsText = actorCards.join('\n\n');
5256
const instructions = `
5357
# Search results:

src/tools/openai/fetch_actor_details.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
resolveOutputOptions,
1010
} from '../../utils/actor_details.js';
1111
import { buildMCPResponse } from '../../utils/mcp.js';
12+
import { getUserPlanTierCached } from '../../utils/userid_cache.js';
1213
import {
1314
fetchActorDetailsMetadata,
1415
fetchActorDetailsToolArgsSchema,
@@ -28,12 +29,13 @@ export const openaiFetchActorDetails: ToolEntry = Object.freeze({
2829
const resolvedOutput = resolveOutputOptions(parsed.output);
2930
const cardOptions = buildCardOptions(resolvedOutput);
3031

31-
const details = await fetchActorDetails(apifyClient, parsed.actor, cardOptions);
32+
const userTier = await getUserPlanTierCached(apifyToken, apifyClient);
33+
const details = await fetchActorDetails(apifyClient, parsed.actor, cardOptions, userTier);
3234
if (!details) {
3335
return buildActorNotFoundResponse(parsed.actor);
3436
}
3537

36-
const { structuredContent: processedStructuredContent, actorUrl } = processActorDetailsForResponse(details);
38+
const { structuredContent: processedStructuredContent, actorUrl } = processActorDetailsForResponse(details, userTier);
3739
const structuredContent = {
3840
actorInfo: details.actorCardStructured,
3941
inputSchema: details.inputSchema,

src/tools/openai/fetch_actor_details_internal.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '../../utils/actor_details.js';
1414
import { compileSchema } from '../../utils/ajv.js';
1515
import { buildMCPResponse } from '../../utils/mcp.js';
16+
import { getUserPlanTierCached } from '../../utils/userid_cache.js';
1617
import { actorDetailsOutputSchema } from '../structured_output_schemas.js';
1718

1819
const fetchActorDetailsInternalArgsSchema = z.object({
@@ -56,7 +57,8 @@ but the user did NOT explicitly ask for Actor details presentation.`,
5657
const resolvedOutput = resolveOutputOptions(parsed.output);
5758
const cardOptions = buildCardOptions(resolvedOutput);
5859

59-
const details = await fetchActorDetails(apifyClient, parsed.actor, cardOptions);
60+
const userTier = await getUserPlanTierCached(apifyToken, apifyClient);
61+
const details = await fetchActorDetails(apifyClient, parsed.actor, cardOptions, userTier);
6062
if (!details) {
6163
return buildActorNotFoundResponse(parsed.actor);
6264
}

src/tools/openai/search_actors.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { ApifyClient } from '../../apify_client.js';
12
import { HelperTools } from '../../const.js';
23
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
34
import type { InternalToolArgs, ToolEntry } from '../../types.js';
45
import { formatActorForWidget, formatActorToActorCard, formatActorToStructuredCard, type WidgetActor } from '../../utils/actor_card.js';
56
import { searchAndFilterActors } from '../../utils/actor_search.js';
67
import { buildMCPResponse } from '../../utils/mcp.js';
8+
import { getUserPlanTierCached } from '../../utils/userid_cache.js';
79
import {
810
searchActorsArgsSchema,
911
searchActorsMetadata,
@@ -39,7 +41,9 @@ You MUST retry with broader, more generic keywords - use just the platform name
3941
return buildMCPResponse({ texts: [instructions], structuredContent });
4042
}
4143

42-
const structuredActorCards = actors.map((actor) => formatActorToStructuredCard(actor));
44+
const userTier = await getUserPlanTierCached(apifyToken, new ApifyClient({ token: apifyToken ?? undefined }));
45+
46+
const structuredActorCards = actors.map((actor) => formatActorToStructuredCard(actor, undefined, userTier));
4347
const structuredContent: {
4448
actors: typeof structuredActorCards;
4549
query: string;
@@ -55,9 +59,9 @@ IMPORTANT: You MUST always do a second search with broader, more generic keyword
5559
};
5660

5761
// Add widget-formatted actors for the interactive UI
58-
structuredContent.widgetActors = actors.map(formatActorForWidget);
62+
structuredContent.widgetActors = actors.map((actor) => formatActorForWidget(actor, userTier));
5963

60-
const actorCards = actors.map((actor) => formatActorToActorCard(actor));
64+
const actorCards = actors.map((actor) => formatActorToActorCard(actor, undefined, userTier));
6165
const actorsText = actorCards.join('\n\n');
6266
const texts = [`
6367
# Search results:

src/tools/structured_output_schemas.ts

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,6 @@ const developerSchema = {
1616
required: ['username', 'isOfficialApify', 'url'],
1717
};
1818

19-
/**
20-
* Schema for tiered pricing within an event
21-
*/
22-
const eventTieredPricingSchema = {
23-
type: 'array' as const, // Literal type required for MCP SDK type compatibility
24-
items: {
25-
type: 'object' as const, // Literal type required for MCP SDK type compatibility
26-
properties: {
27-
tier: { type: 'string' },
28-
priceUsd: { type: 'number' },
29-
},
30-
},
31-
};
32-
3319
/**
3420
* Schema for pricing events (PAY_PER_EVENT model)
3521
*/
@@ -40,40 +26,25 @@ const pricingEventsSchema = {
4026
properties: {
4127
title: { type: 'string', description: 'Event title' },
4228
description: { type: 'string', description: 'Event description' },
43-
priceUsd: { type: 'number', description: 'Price in USD' },
44-
tieredPricing: eventTieredPricingSchema,
29+
priceUsd: { type: 'number', description: 'Resolved price in USD for the user\'s plan tier' },
4530
},
4631
},
4732
description: 'Event-based pricing information',
4833
};
4934

5035
/**
51-
* Schema for tiered pricing (general)
52-
*/
53-
const tieredPricingSchema = {
54-
type: 'array' as const, // Literal type required for MCP SDK type compatibility
55-
items: {
56-
type: 'object' as const, // Literal type required for MCP SDK type compatibility
57-
properties: {
58-
tier: { type: 'string', description: 'Tier name' },
59-
pricePerUnit: { type: 'number', description: 'Price per unit for this tier' },
60-
},
61-
},
62-
description: 'Tiered pricing information',
63-
};
64-
65-
/**
66-
* Schema for pricing information
36+
* Schema for pricing information.
37+
* Prices are resolved to the user's plan tier — no tiered arrays in output.
6738
*/
6839
export const pricingSchema = {
6940
type: 'object' as const, // Literal type required for MCP SDK type compatibility
7041
properties: {
7142
model: { type: 'string', description: 'Pricing model (FREE, PRICE_PER_DATASET_ITEM, FLAT_PRICE_PER_MONTH, PAY_PER_EVENT)' },
7243
isFree: { type: 'boolean', description: 'Whether the Actor is free to use' },
73-
pricePerUnit: { type: 'number', description: 'Price per unit (for non-free models)' },
44+
pricePerUnit: { type: 'number', description: 'Price per unit resolved to the user\'s plan tier' },
7445
unitName: { type: 'string', description: 'Unit name for pricing' },
7546
trialMinutes: { type: 'number', description: 'Trial period in minutes' },
76-
tieredPricing: tieredPricingSchema,
47+
resolvedForTier: { type: 'string', description: 'Plan tier this price was resolved for, or "base" as fallback' },
7748
events: pricingEventsSchema,
7849
},
7950
required: ['model', 'isFree'],

src/utils/actor_card.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { APIFY_STORE_URL } from '../const.js';
2-
import type { Actor, ActorCardOptions, ActorStoreList, PricingInfo, StructuredActorCard } from '../types.js';
2+
import type { Actor, ActorCardOptions, ActorStoreList, PricingInfo, PricingTier, StructuredActorCard } from '../types.js';
33
import { getCurrentPricingInfo, pricingInfoToString, pricingInfoToStructured, type StructuredPricingInfo } from './pricing_info.js';
44

55
// Helper function to format categories from uppercase with underscores to proper case
@@ -32,6 +32,7 @@ export function formatActorToActorCard(
3232
includeRating: true,
3333
includeMetadata: true,
3434
},
35+
userTier?: PricingTier | null,
3536
): string {
3637
const actorFullName = `${actor.username}/${actor.name}`;
3738
const actorUrl = `${APIFY_STORE_URL}/${actorFullName}`;
@@ -52,11 +53,11 @@ export function formatActorToActorCard(
5253
let pricingInfo: string;
5354
if ('currentPricingInfo' in actor) {
5455
// ActorStoreList has currentPricingInfo
55-
pricingInfo = pricingInfoToString(actor.currentPricingInfo);
56+
pricingInfo = pricingInfoToString(actor.currentPricingInfo, userTier);
5657
} else {
5758
// Actor has pricingInfos array
5859
const currentPricingInfo = getCurrentPricingInfo(actor.pricingInfos || [], new Date());
59-
pricingInfo = pricingInfoToString(currentPricingInfo);
60+
pricingInfo = pricingInfoToString(currentPricingInfo, userTier);
6061
}
6162
markdownLines.push(`- **[Pricing](${actorUrl}/pricing):** ${pricingInfo}`);
6263
}
@@ -142,6 +143,7 @@ export function formatActorToStructuredCard(
142143
includeRating: true,
143144
includeMetadata: true,
144145
},
146+
userTier?: PricingTier | null,
145147
): StructuredActorCard {
146148
const actorFullName = `${actor.username}/${actor.name}`;
147149
const actorUrl = `${APIFY_STORE_URL}/${actorFullName}`;
@@ -181,7 +183,7 @@ export function formatActorToStructuredCard(
181183
pricingInfo = getCurrentPricingInfo(actor.pricingInfos, new Date());
182184
}
183185
// If pricingInfo is still null, it means the actor is free (no pricing info means free)
184-
structuredData.pricing = pricingInfoToStructured(pricingInfo);
186+
structuredData.pricing = pricingInfoToStructured(pricingInfo, userTier);
185187
}
186188

187189
// Add metadata (deprecation warning)
@@ -285,6 +287,7 @@ export type WidgetActor = {
285287
*/
286288
export function formatActorForWidget(
287289
actor: ActorStoreList,
290+
userTier?: PricingTier | null,
288291
): WidgetActor {
289292
return {
290293
id: actor.id,
@@ -300,7 +303,7 @@ export function formatActorForWidget(
300303
totalUsers: actor.stats?.totalUsers || 0,
301304
},
302305
url: `${APIFY_STORE_URL}/${actor.username}/${actor.name}`,
303-
currentPricingInfo: pricingInfoToStructured(actor.currentPricingInfo),
306+
currentPricingInfo: pricingInfoToStructured(actor.currentPricingInfo, userTier),
304307
};
305308
}
306309

@@ -314,6 +317,7 @@ export function formatActorForWidget(
314317
export function formatActorDetailsForWidget(
315318
actor: Actor,
316319
actorUrl: string,
320+
userTier?: PricingTier | null,
317321
): WidgetActor {
318322
const currentPricingInfo = getCurrentPricingInfo(actor.pricingInfos || [], new Date());
319323

@@ -331,6 +335,6 @@ export function formatActorDetailsForWidget(
331335
actorReviewRating: actor.stats?.actorReviewRating || 0,
332336
actorReviewCount: actor.stats?.actorReviewCount || 0,
333337
},
334-
currentPricingInfo: pricingInfoToStructured(currentPricingInfo),
338+
currentPricingInfo: pricingInfoToStructured(currentPricingInfo, userTier),
335339
};
336340
}

src/utils/actor_details.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ApifyClient } from '../apify_client.js';
55
import { HelperTools, TOOL_STATUS } from '../const.js';
66
import { connectMCPClient } from '../mcp/client.js';
77
import { filterSchemaProperties, shortenProperties } from '../tools/utils.js';
8-
import type { Actor, ActorCardOptions, ActorInputSchema, ActorStoreList, StructuredActorCard } from '../types.js';
8+
import type { Actor, ActorCardOptions, ActorInputSchema, ActorStoreList, PricingTier, StructuredActorCard } from '../types.js';
99
import { getActorMcpUrlCached } from './actor.js';
1010
import { formatActorDetailsForWidget, formatActorToActorCard, formatActorToStructuredCard } from './actor_card.js';
1111
import { searchActorsByKeywords } from './actor_search.js';
@@ -87,6 +87,7 @@ export async function fetchActorDetails(
8787
apifyClient: ApifyClient,
8888
actorName: string,
8989
cardOptions?: ActorCardOptions,
90+
userTier?: PricingTier | null,
9091
): Promise<ActorDetailsResult | null> {
9192
try {
9293
const [actorInfo, buildInfo, storeActors]: [Actor | undefined, Build | undefined, ActorStoreList[]] = await Promise.all([
@@ -109,8 +110,8 @@ export async function fetchActorDetails(
109110
}) as ActorInputSchema;
110111
inputSchema.properties = filterSchemaProperties(inputSchema.properties);
111112
inputSchema.properties = shortenProperties(inputSchema.properties);
112-
const actorCard = formatActorToActorCard(actorInfoWithPicture, cardOptions);
113-
const actorCardStructured = formatActorToStructuredCard(actorInfoWithPicture, cardOptions);
113+
const actorCard = formatActorToActorCard(actorInfoWithPicture, cardOptions, userTier);
114+
const actorCardStructured = formatActorToStructuredCard(actorInfoWithPicture, cardOptions, userTier);
114115
return {
115116
actorInfo: actorInfoWithPicture,
116117
buildInfo,
@@ -132,7 +133,7 @@ export async function fetchActorDetails(
132133
* @param details - Raw actor details from fetchActorDetails
133134
* @returns Processed actor details with formatted content
134135
*/
135-
export function processActorDetailsForResponse(details: ActorDetailsResult) {
136+
export function processActorDetailsForResponse(details: ActorDetailsResult, userTier?: PricingTier | null) {
136137
const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`;
137138
// Add link to README title
138139
const formattedReadme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `);
@@ -150,7 +151,7 @@ export function processActorDetailsForResponse(details: ActorDetailsResult) {
150151

151152
const structuredContent = {
152153
actorDetails: {
153-
actorInfo: formatActorDetailsForWidget(details.actorInfo, actorUrl),
154+
actorInfo: formatActorDetailsForWidget(details.actorInfo, actorUrl, userTier),
154155
actorCard: details.actorCard,
155156
readme: formattedReadme,
156157
inputSchema: details.inputSchema,

0 commit comments

Comments
 (0)