Skip to content

Commit 36cb663

Browse files
claudejirispilka
authored andcommitted
feat: simplify pricing output in search-actors
Search results dumped all 6 pricing tiers (FREE through DIAMOND) for every event of every actor, bloating output with no value (e.g. Google Maps Scraper returned 9 events x 6 tiers = 54 price entries). Only the user's tier matters in a search listing. Now search-actors shows only the user's tier price (FREE fallback) plus a short hint about other tiers. fetch-actor-details is untouched and still returns full tiered pricing. - Extend user cache to also return userPlanTier from the same API call - Add pricingInfoToSimplifiedString and pricingInfoToSimplifiedStructured - Add userTier to ActorCardOptions, branch in card formatters - Add pricingNote field to StructuredPricingInfo for the hint
1 parent b2d350e commit 36cb663

11 files changed

Lines changed: 475 additions & 30 deletions

src/mcp/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import { createProgressTracker } from '../utils/progress.js';
7676
import { getServerInstructions } from '../utils/server-instructions/index.js';
7777
import { classifyFailureCategory, extractAjvErrorDetails, extractToolTelemetry, getToolStatusFromError } from '../utils/tool_status.js';
7878
import { buildActorFields, extractActorId, extractActorName, getToolFullName, getToolPublicFieldOnly } from '../utils/tools.js';
79-
import { getUserIdFromTokenCached } from '../utils/userid_cache.js';
79+
import { getUserInfoCached } from '../utils/userid_cache.js';
8080
import { getPackageVersion } from '../utils/version.js';
8181
import { connectMCPClient } from './client.js';
8282
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js';
@@ -1364,7 +1364,7 @@ export class ActorsMcpServer {
13641364
let userId: string | null = null;
13651365
if (apifyToken) {
13661366
const apifyClient = new ApifyClient({ token: apifyToken });
1367-
userId = await getUserIdFromTokenCached(apifyToken, apifyClient);
1367+
({ userId } = await getUserInfoCached(apifyToken, apifyClient));
13681368
log.debug('Telemetry: fetched userId', { userId, mcpSessionId });
13691369
}
13701370
const capabilities = this.options.initializeRequestData?.params?.capabilities;

src/tools/core/search_actors_common.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { z } from 'zod';
44
import { HelperTools } from '../../const.js';
55
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
66
import type { ActorStoreList, HelperTool, StructuredActorCard, ToolInputSchema } from '../../types.js';
7-
import { formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
7+
import { DEFAULT_CARD_OPTIONS, formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
88
import { compileSchema } from '../../utils/ajv.js';
99
import { buildMCPResponse } from '../../utils/mcp.js';
10+
import type { PricingTier } from '../../utils/pricing_info.js';
1011
import { actorSearchOutputSchema } from '../structured_output_schemas.js';
1112

1213
/**
@@ -112,10 +113,12 @@ export type SearchActorsResult = {
112113

113114
export function buildSearchActorsResult(
114115
actors: ActorStoreList[],
116+
userTier?: PricingTier,
115117
): SearchActorsResult {
118+
const options = { ...DEFAULT_CARD_OPTIONS, userTier };
116119
return {
117-
actorCardText: actors.map((actor) => formatActorToActorCard(actor)).join('\n\n'),
118-
actorCardStructured: actors.map((actor) => formatActorToStructuredCard(actor)),
120+
actorCardText: actors.map((actor) => formatActorToActorCard(actor, options)).join('\n\n'),
121+
actorCardStructured: actors.map((actor) => formatActorToStructuredCard(actor, options)),
119122
};
120123
}
121124

src/tools/default/search_actors.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HelperTools } from '../../const.js';
44
import type { InternalToolArgs, ToolEntry } from '../../types.js';
55
import { searchAndFilterActors } from '../../utils/actor_search.js';
66
import { buildMCPResponse } from '../../utils/mcp.js';
7+
import { getUserInfoCached } from '../../utils/userid_cache.js';
78
import {
89
buildSearchActorsEmptyResponse,
910
buildSearchActorsResult,
@@ -18,7 +19,7 @@ import {
1819
export const defaultSearchActors: ToolEntry = Object.freeze({
1920
...searchActorsMetadata,
2021
call: async (toolArgs: InternalToolArgs) => {
21-
const { args, apifyToken, userRentedActorIds, apifyMcpServer } = toolArgs;
22+
const { args, apifyToken, apifyClient, userRentedActorIds, apifyMcpServer } = toolArgs;
2223
const parsed = searchActorsArgsSchema.parse(args);
2324
const actors = await searchAndFilterActors({
2425
keywords: parsed.keywords,
@@ -33,7 +34,8 @@ export const defaultSearchActors: ToolEntry = Object.freeze({
3334
return buildSearchActorsEmptyResponse(parsed.keywords);
3435
}
3536

36-
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors);
37+
const { userPlanTier } = await getUserInfoCached(apifyToken, apifyClient);
38+
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier);
3739
const structuredContent = {
3840
actors: actorCardStructured,
3941
query: parsed.keywords,

src/tools/openai/search_actors.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { InternalToolArgs, ToolEntry } from '../../types.js';
66
import { formatActorForWidget, type WidgetActor } from '../../utils/actor_card.js';
77
import { searchAndFilterActors } from '../../utils/actor_search.js';
88
import { buildMCPResponse } from '../../utils/mcp.js';
9+
import { getUserInfoCached } from '../../utils/userid_cache.js';
910
import {
1011
buildSearchActorsEmptyResponse,
1112
buildSearchActorsResult,
@@ -20,7 +21,7 @@ import {
2021
export const openaiSearchActors: ToolEntry = Object.freeze({
2122
...searchActorsMetadata,
2223
call: async (toolArgs: InternalToolArgs) => {
23-
const { args, apifyToken, userRentedActorIds, apifyMcpServer } = toolArgs;
24+
const { args, apifyToken, apifyClient, userRentedActorIds, apifyMcpServer } = toolArgs;
2425
const parsed = searchActorsArgsSchema.parse(args);
2526
const actors = await searchAndFilterActors({
2627
keywords: parsed.keywords,
@@ -35,7 +36,8 @@ export const openaiSearchActors: ToolEntry = Object.freeze({
3536
return buildSearchActorsEmptyResponse(parsed.keywords);
3637
}
3738

38-
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors);
39+
const { userPlanTier } = await getUserInfoCached(apifyToken, apifyClient);
40+
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier);
3941
const structuredContent: {
4042
actors: typeof actorCardStructured;
4143
query: string;

src/tools/structured_output_schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const pricingSchema = {
7575
trialMinutes: { type: 'number', description: 'Trial period in minutes' },
7676
tieredPricing: tieredPricingSchema,
7777
events: pricingEventsSchema,
78+
pricingNote: { type: 'string', description: 'Note about additional pricing tiers available via fetch-actor-details' },
7879
},
7980
required: ['model', 'isFree'],
8081
};

src/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { FAILURE_CATEGORY, TELEMETRY_ENV, TOOL_STATUS } from './const.js';
1818
import type { ActorsMcpServer } from './mcp/server.js';
1919
import type { PaymentProvider } from './payments/types.js';
2020
import type { CATEGORY_NAMES } from './tools/categories.js';
21-
import type { StructuredPricingInfo } from './utils/pricing_info.js';
21+
import type { PricingTier, StructuredPricingInfo } from './utils/pricing_info.js';
2222
import type { ProgressTracker } from './utils/progress.js';
2323

2424
export type SchemaProperties = {
@@ -616,6 +616,12 @@ export type ActorCardOptions = {
616616
includeRating?: boolean;
617617
/** Include metadata (developer, categories, last modified date, deprecation warning) */
618618
includeMetadata?: boolean;
619+
/**
620+
* If set, simplify pricing to only show this tier's price (with FREE fallback)
621+
* and append a hint about other tiers. Used by search-actors.
622+
* When undefined, full pricing is shown (used by fetch-actor-details).
623+
*/
624+
userTier?: PricingTier;
619625
}
620626

621627
/**

src/utils/actor_card.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { Actor, ActorCardOptions, ActorStoreList, StructuredActorCard } fro
33
import {
44
getCurrentPricingInfo,
55
type PricingInfo,
6+
pricingInfoToSimplifiedString,
7+
pricingInfoToSimplifiedStructured,
68
pricingInfoToString,
79
pricingInfoToStructured,
810
type StructuredPricingInfo,
@@ -34,7 +36,7 @@ function getActorPricingInfo(actor: Actor | ActorStoreList): PricingInfo | null
3436
return getCurrentPricingInfo(actor.pricingInfos || [], new Date());
3537
}
3638

37-
const DEFAULT_CARD_OPTIONS: ActorCardOptions = {
39+
export const DEFAULT_CARD_OPTIONS: ActorCardOptions = {
3840
includeDescription: true,
3941
includeStats: true,
4042
includePricing: true,
@@ -180,7 +182,9 @@ export function formatActorToActorCard(
180182
}
181183

182184
if (options.includePricing) {
183-
const pricingString = pricingInfoToString(data.pricingInfo);
185+
const pricingString = options.userTier
186+
? pricingInfoToSimplifiedString(data.pricingInfo, options.userTier)
187+
: pricingInfoToString(data.pricingInfo);
184188
markdownLines.push(`- **[Pricing](${data.actorUrl}/pricing):** ${pricingString}`);
185189
}
186190

@@ -224,6 +228,13 @@ export function formatActorToStructuredCard(
224228
): StructuredActorCard {
225229
const data = extractActorData(actor, options);
226230

231+
let pricing: StructuredPricingInfo = { model: 'FREE', isFree: true };
232+
if (options.includePricing) {
233+
pricing = options.userTier
234+
? pricingInfoToSimplifiedStructured(data.pricingInfo, options.userTier)
235+
: pricingInfoToStructured(data.pricingInfo);
236+
}
237+
227238
return {
228239
title: data.title,
229240
url: data.actorUrl,
@@ -233,9 +244,7 @@ export function formatActorToStructuredCard(
233244
developer: data.developer,
234245
description: data.description,
235246
categories: data.categories,
236-
pricing: options.includePricing
237-
? pricingInfoToStructured(data.pricingInfo)
238-
: { model: 'FREE', isFree: true },
247+
pricing,
239248
stats: data.stats,
240249
rating: data.rating,
241250
modifiedAt: data.modifiedAt,

src/utils/pricing_info.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,30 @@ export type StructuredPricingInfo = {
5757
priceUsd: number;
5858
}[];
5959
}[];
60+
/** Hint added when pricing is simplified to a single tier. */
61+
pricingNote?: string;
6062
};
6163

64+
const SIMPLIFIED_PRICING_NOTE = 'Higher subscription tiers may offer lower prices. Use fetch-actor-details for complete pricing.';
65+
66+
/**
67+
* Picks the user's tier from a tiered pricing map, falling back to FREE, then the first available tier.
68+
* Returns null if the map is empty.
69+
*/
70+
function pickTierEntry<T>(
71+
tieredMap: Record<string, T> | undefined,
72+
userTier: PricingTier,
73+
): { tier: string; value: T } | null {
74+
if (!tieredMap) return null;
75+
const entries = Object.entries(tieredMap);
76+
if (entries.length === 0) return null;
77+
const userMatch = entries.find(([t]) => t === userTier);
78+
if (userMatch) return { tier: userMatch[0], value: userMatch[1] };
79+
const freeMatch = entries.find(([t]) => t === 'FREE');
80+
if (freeMatch) return { tier: freeMatch[0], value: freeMatch[1] };
81+
return { tier: entries[0][0], value: entries[0][1] };
82+
}
83+
6284
/**
6385
* Returns the most recent valid pricing information from a list of pricing infos,
6486
* based on the provided current date.
@@ -221,3 +243,99 @@ export function pricingInfoToStructured(pricingInfo: PricingInfo | null): Struct
221243

222244
return structuredPricing;
223245
}
246+
247+
/**
248+
* Simplified text pricing for search-actors: shows only the user's tier price
249+
* (with FREE fallback) and appends a hint about other tiers.
250+
*
251+
* Defaults to FREE when `userTier` is undefined.
252+
*/
253+
export function pricingInfoToSimplifiedString(
254+
pricingInfo: PricingInfo | null,
255+
userTier: PricingTier = 'FREE',
256+
): string {
257+
if (pricingInfo === null || pricingInfo.pricingModel === ACTOR_PRICING_MODEL.FREE) {
258+
return 'This Actor is free to use. You are only charged for Apify platform usage.';
259+
}
260+
if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.PRICE_PER_DATASET_ITEM) {
261+
const customUnitName = pricingInfo.unitName !== 'result' ? pricingInfo.unitName : '';
262+
const unitLabel = customUnitName || 'results';
263+
const picked = pickTierEntry(pricingInfo.tieredPricing, userTier);
264+
if (picked) {
265+
return `This Actor charges per results${customUnitName ? ` (in this case named ${customUnitName})` : ''}; price per 1000 ${unitLabel} for ${picked.tier} tier: $${picked.value.tieredPricePerUnitUsd * 1000}. ${SIMPLIFIED_PRICING_NOTE}`;
266+
}
267+
return `This Actor charges per results${customUnitName ? ` (in this case named ${customUnitName})` : ''}; the price per 1000 ${unitLabel} is ${(pricingInfo.pricePerUnitUsd as number) * 1000} USD.`;
268+
}
269+
if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.FLAT_PRICE_PER_MONTH) {
270+
const { value, unit } = convertMinutesToGreatestUnit(pricingInfo.trialMinutes || 0);
271+
const picked = pickTierEntry(pricingInfo.tieredPricing, userTier);
272+
if (picked) {
273+
return `This Actor is rental; price for ${picked.tier} tier: $${picked.value.tieredPricePerUnitUsd} per month, with a trial period of ${value} ${unit}. ${SIMPLIFIED_PRICING_NOTE}`;
274+
}
275+
return `This Actor is rental and has a flat price of ${pricingInfo.pricePerUnitUsd} USD per month, with a trial period of ${value} ${unit}.`;
276+
}
277+
if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.PAY_PER_EVENT) {
278+
const events = pricingInfo.pricingPerEvent?.actorChargeEvents;
279+
if (!events) return 'Pricing information for events is not available.';
280+
const lines: string[] = [];
281+
let hasTieredEvent = false;
282+
for (const rawEvent of Object.values(events)) {
283+
const event = rawEvent as ActorChargeEvent;
284+
let line = `\t- **${event.eventTitle}**: ${event.eventDescription} `;
285+
if (typeof event.eventPriceUsd === 'number') {
286+
line += `(Flat price: $${event.eventPriceUsd} per event)`;
287+
} else if (event.eventTieredPricingUsd) {
288+
hasTieredEvent = true;
289+
const picked = pickTierEntry(event.eventTieredPricingUsd, userTier);
290+
line += picked
291+
? `(${picked.tier} tier: $${picked.value.tieredEventPriceUsd} per event)`
292+
: '(No price info)';
293+
} else {
294+
line += '(No price info)';
295+
}
296+
lines.push(line);
297+
}
298+
const suffix = hasTieredEvent ? `\n${SIMPLIFIED_PRICING_NOTE}` : '';
299+
return `This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for the following events:\n${lines.join('\n')}${suffix}`;
300+
}
301+
return 'Pricing information is not available.';
302+
}
303+
304+
/**
305+
* Simplified structured pricing for search-actors: collapses tiered pricing arrays
306+
* to a single entry for the user's tier (with FREE fallback) and sets `pricingNote`.
307+
*
308+
* Defaults to FREE when `userTier` is undefined.
309+
*/
310+
export function pricingInfoToSimplifiedStructured(
311+
pricingInfo: PricingInfo | null,
312+
userTier: PricingTier = 'FREE',
313+
): StructuredPricingInfo {
314+
const result = pricingInfoToStructured(pricingInfo);
315+
let simplified = false;
316+
317+
if (result.tieredPricing && result.tieredPricing.length > 0) {
318+
const map = Object.fromEntries(result.tieredPricing.map((t) => [t.tier, t]));
319+
const picked = pickTierEntry(map, userTier);
320+
if (picked) {
321+
result.tieredPricing = [picked.value];
322+
simplified = true;
323+
}
324+
}
325+
326+
if (result.events) {
327+
result.events = result.events.map((event) => {
328+
if (!event.tieredPricing || event.tieredPricing.length === 0) return event;
329+
const map = Object.fromEntries(event.tieredPricing.map((t) => [t.tier, t]));
330+
const picked = pickTierEntry(map, userTier);
331+
if (!picked) return event;
332+
simplified = true;
333+
return { ...event, tieredPricing: [picked.value] };
334+
});
335+
}
336+
337+
if (simplified) {
338+
result.pricingNote = SIMPLIFIED_PRICING_NOTE;
339+
}
340+
return result;
341+
}

src/utils/userid_cache.ts

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,50 @@ import { createHash } from 'node:crypto';
22

33
import type { ApifyClient } from '../apify_client.js';
44
import { USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS } from '../const.js';
5+
import type { PricingTier } from './pricing_info.js';
56
import { TTLLRUCache } from './ttl_lru.js';
67

7-
// LRU cache with TTL for user info - stores the raw User object from API
8-
const userIdCache = new TTLLRUCache<string>(USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS);
8+
const VALID_PRICING_TIERS = new Set<PricingTier>(['FREE', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']);
9+
10+
export type CachedUserInfo = {
11+
userId: string | null;
12+
userPlanTier: PricingTier;
13+
};
14+
15+
// LRU cache with TTL for user info - keyed by hashed token
16+
const userInfoCache = new TTLLRUCache<CachedUserInfo>(USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS);
17+
18+
function normalizePlanTier(planId: string | undefined): PricingTier {
19+
if (!planId) return 'FREE';
20+
const upper = planId.toUpperCase();
21+
return VALID_PRICING_TIERS.has(upper as PricingTier) ? (upper as PricingTier) : 'FREE';
22+
}
923

1024
/**
11-
* Gets user ID from token, using cache to avoid repeated API calls
12-
* Token is hashed before caching to avoid storing raw tokens
13-
* Returns userId or null if not found
25+
* Gets user info (id + plan tier) from token, using cache to avoid repeated API calls.
26+
* Token is hashed before caching to avoid storing raw tokens.
27+
*
28+
* Defensive defaults: `userPlanTier` is always present. Returns 'FREE' when the plan
29+
* is missing, unrecognized, or the API call fails. Failed lookups are NOT cached so
30+
* the next call retries.
1431
*/
15-
export async function getUserIdFromTokenCached(
32+
export async function getUserInfoCached(
1633
token: string,
1734
apifyClient: ApifyClient,
18-
): Promise<string | null> {
35+
): Promise<CachedUserInfo> {
1936
const tokenHash = createHash('sha256').update(token).digest('hex');
20-
const cachedId = userIdCache.get(tokenHash);
21-
if (cachedId) return cachedId;
37+
const cached = userInfoCache.get(tokenHash);
38+
if (cached) return cached;
2239

2340
try {
2441
const user = await apifyClient.user('me').get();
25-
if (!user || !user.id) {
26-
return null;
27-
}
28-
userIdCache.set(tokenHash, user.id);
29-
return user.id;
42+
const info: CachedUserInfo = {
43+
userId: user?.id ?? null,
44+
userPlanTier: normalizePlanTier(user?.plan?.id),
45+
};
46+
userInfoCache.set(tokenHash, info);
47+
return info;
3048
} catch {
31-
return null;
49+
return { userId: null, userPlanTier: 'FREE' };
3250
}
3351
}

0 commit comments

Comments
 (0)