Skip to content

Commit d7288ba

Browse files
jirispilkaclaudeCopilot
authored
feat: Add simplified pricing display for search-actors with user tier (#695)
* 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 * feat: simplify pricing output handling and remove deprecated functions * feat: update normalizePlanTier function to accept tier parameter and improve user plan handling * feat: Pricing output handling with tier-aware simplification for user plans * feat: omit event descriptions for large event lists * fix: Review commnets * feat: enhance pricing output by defaulting to 'FREE' tier and adding priceUsd to events * fix: Review commnets * fix: Review comments * fix: tests * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: simplify pricing output by omitting conflicting pricing fields and enhancing tier resolution logic * fix: simplify pricing output by refactoring event detail formatting and enhancing tier resolution logic * fix: update styled component props, adjust schemas, and add tests for pricing omission logic --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent ad8f4a0 commit d7288ba

22 files changed

Lines changed: 1540 additions & 212 deletions

res/pricing_output_contract.md

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
---
2+
name: Pricing output contract
3+
description: Contract for `pricingInfoToStructured` and `pricingInfoToString` — what `fetch-actor-details` and `search-actors` return.
4+
---
5+
6+
# Pricing output contract
7+
8+
Contract for `pricingInfoToStructured` and `pricingInfoToString` in `src/utils/pricing_info.ts`.
9+
10+
## Goal
11+
12+
Keep the contract stable. This branch diverged; realign it to:
13+
14+
- `fetch-actor-details`**complete** pricing, same as master.
15+
- `search-actors`**simplified** pricing, same shape as master but `tieredPricing` filtered to the user's tier. Unknown/missing tier → fall back to `FREE`.
16+
17+
**Structured data shape is identical in both modes.** Every field that appears in complete mode appears in simplified mode. The differences in simplified mode:
18+
19+
- `tieredPricing` arrays contain exactly one entry (the resolved tier).
20+
- `PAY_PER_EVENT` events that were filtered from a tiered map also carry top-level `priceUsd` = resolved tier's price. Widget clients that skip the FREE tier in `tieredPricing` (see `src/web/src/utils/formatting.ts`) need this as a fallback so FREE-tier users see a concrete price instead of a generic "Pay per event" string.
21+
- Top-level `pricingNote` is set only when the Actor has **multiple tiers** *and* they resolve consistently. Single-tier Actors don't get a note (the "higher tiers may offer lower prices" promise is vacuous).
22+
- For `PAY_PER_EVENT` only: when `events.length > 5`, event `description` is omitted from simplified output and top-level `eventDescriptionsOmitted` / `eventDescriptionsNote` are set.
23+
24+
Both modes include top-level `userTier` (the user's plan tier).
25+
26+
`isFree` is not part of the output — consumers derive it from `model === 'FREE'`.
27+
28+
## Two modes
29+
30+
| Caller | Mode | `forTier` arg | `tieredPricing` array | `pricingNote` |
31+
|---|---|---|---|---|
32+
| `fetch-actor-details` | **complete** | always provided | full matrix, all tiers | absent |
33+
| `search-actors` | **simplified** | always provided | one entry — the resolved tier | present when the resolved tier is consistent across the Actor |
34+
35+
## Rules
36+
37+
1. Complete mode preserves the **full tiered matrix** from master (no tier data lost) and adds `userTier`. The **text** representation is reformatted from master for consistency with simplified mode — see *Text output notes* below.
38+
2. Simplified mode has the same field shape as complete mode; `tieredPricing` arrays are filtered to a single entry. Top-level `pricingNote` is added when the resolved tier is consistent across the Actor.
39+
3. Tier resolution (simplified mode only): (a) `forTier` match in the actor's map, (b) `FREE` fallback, (c) first entry.
40+
4. `userTier` is always the user's plan tier (the `forTier` value), even if we had to fall back to a different tier for prices in simplified mode.
41+
5. `pricingNote` appears in simplified mode only when the Actor has **multiple tiers** (for `PRICE_PER_DATASET_ITEM` / `FLAT_PRICE_PER_MONTH`: `tieredPricing` has more than one entry; for `PAY_PER_EVENT`: at least one event's tiered map has more than one entry) **and** tiers resolve consistently across the Actor. It names the **resolved** tier (not `userTier`, if they differ). Single-tier Actors never get a note.
42+
6. `pricingNote` is **omitted** in simplified mode when PAY_PER_EVENT events resolve to different tiers (e.g. event A offers GOLD, event B only offers FREE — no single actor-level label is truthful). Per-event `tieredPricing` arrays are still filtered correctly.
43+
7. Simplified `PAY_PER_EVENT` keeps event descriptions when `events.length <= 5`.
44+
8. Simplified `PAY_PER_EVENT` omits event descriptions when `events.length > 5`, keeps title + price, and sets:
45+
- `eventDescriptionsOmitted: true`
46+
- `eventDescriptionsNote: "Event descriptions were omitted because this actor has many pricing events. Use fetch-actor-details for full pricing details."`
47+
9. Single-tier actors (raw data already has only one tier) produce the same `tieredPricing` output in both modes — a 1-element array.
48+
10. `trialMinutes` is shown in both modes for `FLAT_PRICE_PER_MONTH`.
49+
11. `FREE` actors (`pricingModel === 'FREE'` or null input) return `{ "model": "FREE", "userTier": "<tier>" }` in both modes.
50+
51+
## `pricingNote` wording
52+
53+
```
54+
Prices shown are for <TIER> tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table.
55+
```
56+
57+
`<TIER>``FREE`, `BRONZE`, `SILVER`, `GOLD`, `PLATINUM`, `DIAMOND` — the **resolved** tier.
58+
59+
---
60+
61+
## Examples
62+
63+
Using `compass/crawler-google-places` pricing (`PAY_PER_EVENT`). User on `GOLD` in all examples unless noted.
64+
65+
### E1. `fetch-actor-details` — complete mode
66+
67+
**Structured:**
68+
```json
69+
{
70+
"model": "PAY_PER_EVENT",
71+
"userTier": "GOLD",
72+
"events": [
73+
{
74+
"title": "Scraped place", "description": "...",
75+
"tieredPricing": [
76+
{ "tier": "FREE", "priceUsd": 0.004 },
77+
{ "tier": "BRONZE", "priceUsd": 0.004 },
78+
{ "tier": "SILVER", "priceUsd": 0.003 },
79+
{ "tier": "GOLD", "priceUsd": 0.0021 },
80+
{ "tier": "PLATINUM", "priceUsd": 0.00126 },
81+
{ "tier": "DIAMOND", "priceUsd": 0.00076 }
82+
]
83+
},
84+
{ "title": "Actor start", "description": "...", "priceUsd": 0.00005 }
85+
]
86+
}
87+
```
88+
89+
**Text:**
90+
```
91+
This Actor is paid per event:
92+
- **Scraped place**: ... (FREE: $0.004, BRONZE: $0.004, SILVER: $0.003, GOLD: $0.0021, PLATINUM: $0.00126, DIAMOND: $0.00076 per event)
93+
- **Actor start**: ... ($0.00005 per event)
94+
```
95+
96+
### E2. `search-actors`, user on GOLD
97+
98+
**Structured:** same shape as E1, `tieredPricing` filtered, `pricingNote` added. Simplified mode also mirrors the resolved price into top-level `priceUsd` on tiered events so the widget can render it for FREE-tier users (the widget's tier list skips FREE and falls back to `priceUsd`).
99+
```json
100+
{
101+
"model": "PAY_PER_EVENT",
102+
"userTier": "GOLD",
103+
"events": [
104+
{
105+
"title": "Scraped place", "description": "...",
106+
"priceUsd": 0.0021,
107+
"tieredPricing": [{ "tier": "GOLD", "priceUsd": 0.0021 }]
108+
},
109+
{ "title": "Actor start", "description": "...", "priceUsd": 0.00005 }
110+
],
111+
"pricingNote": "Prices shown are for GOLD tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table."
112+
}
113+
```
114+
115+
**Text:**
116+
```
117+
This Actor is paid per event:
118+
- **Scraped place**: ... ($0.0021 per event)
119+
- **Actor start**: ... ($0.00005 per event)
120+
Prices shown are for GOLD tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table.
121+
```
122+
123+
### E3. `search-actors`, user on DIAMOND but actor doesn't offer DIAMOND → fall back to FREE
124+
125+
**Structured:** `userTier` is still `DIAMOND` (the user's plan). `tieredPricing` uses the FREE fallback. `pricingNote` names FREE (the **resolved** tier). Top-level `priceUsd` mirrors the resolved price (widget fallback).
126+
```json
127+
{
128+
"model": "PAY_PER_EVENT",
129+
"userTier": "DIAMOND",
130+
"events": [
131+
{
132+
"title": "Scraped place", "description": "...",
133+
"priceUsd": 0.004,
134+
"tieredPricing": [{ "tier": "FREE", "priceUsd": 0.004 }]
135+
},
136+
{ "title": "Actor start", "description": "...", "priceUsd": 0.00005 }
137+
],
138+
"pricingNote": "Prices shown are for FREE tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table."
139+
}
140+
```
141+
142+
### E4. Single-tier actor (only FREE bucket defined)
143+
144+
**Complete mode** (fetch-actor-details) — `tieredPricing` is preserved as a 1-element array, no `pricingNote`:
145+
```json
146+
{
147+
"model": "PAY_PER_EVENT",
148+
"userTier": "GOLD",
149+
"events": [
150+
{
151+
"title": "Scraped place", "description": "...",
152+
"tieredPricing": [{ "tier": "FREE", "priceUsd": 0.004 }]
153+
}
154+
]
155+
}
156+
```
157+
158+
**Simplified mode** (search-actors) — same shape plus `priceUsd` for widget fallback. **No `pricingNote`** because the Actor has only one tier:
159+
```json
160+
{
161+
"model": "PAY_PER_EVENT",
162+
"userTier": "GOLD",
163+
"events": [
164+
{
165+
"title": "Scraped place", "description": "...",
166+
"priceUsd": 0.004,
167+
"tieredPricing": [{ "tier": "FREE", "priceUsd": 0.004 }]
168+
}
169+
]
170+
}
171+
```
172+
173+
**Text (both modes, identical):**
174+
```
175+
This Actor is paid per event:
176+
- **Scraped place**: ... ($0.004 per event)
177+
```
178+
179+
### E5. `PRICE_PER_DATASET_ITEM`, `fetch-actor-details`
180+
181+
**Structured:**
182+
```json
183+
{
184+
"model": "PRICE_PER_DATASET_ITEM",
185+
"userTier": "GOLD",
186+
"pricePerUnit": 0.005,
187+
"unitName": "result",
188+
"tieredPricing": [
189+
{ "tier": "FREE", "pricePerUnit": 0.005 },
190+
{ "tier": "BRONZE", "pricePerUnit": 0.004 },
191+
{ "tier": "GOLD", "pricePerUnit": 0.002 }
192+
]
193+
}
194+
```
195+
196+
**Text:**
197+
```
198+
This Actor has tiered pricing per 1000 results: FREE: $5, BRONZE: $4, GOLD: $2.
199+
```
200+
201+
### E6. `PRICE_PER_DATASET_ITEM`, `search-actors`, user on GOLD
202+
203+
**Structured:**
204+
```json
205+
{
206+
"model": "PRICE_PER_DATASET_ITEM",
207+
"userTier": "GOLD",
208+
"pricePerUnit": 0.002,
209+
"unitName": "result",
210+
"tieredPricing": [{ "tier": "GOLD", "pricePerUnit": 0.002 }],
211+
"pricingNote": "Prices shown are for GOLD tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table."
212+
}
213+
```
214+
215+
**Text:**
216+
```
217+
This Actor costs $2 per 1000 results. Prices shown are for GOLD tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table.
218+
```
219+
220+
### E7. `FLAT_PRICE_PER_MONTH`, `search-actors`, user on GOLD
221+
222+
**Structured:**
223+
```json
224+
{
225+
"model": "FLAT_PRICE_PER_MONTH",
226+
"userTier": "GOLD",
227+
"pricePerUnit": 20,
228+
"trialMinutes": 10080,
229+
"tieredPricing": [{ "tier": "GOLD", "pricePerUnit": 20 }],
230+
"pricingNote": "Prices shown are for GOLD tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table."
231+
}
232+
```
233+
234+
**Text:**
235+
```
236+
This Actor is rental and costs $20 per month, with a trial period of 7 days. Prices shown are for GOLD tier. Higher tiers may offer lower prices — use fetch-actor-details to see the full pricing table.
237+
```
238+
239+
### E8. FREE actor
240+
241+
**Structured (both modes):**
242+
```json
243+
{ "model": "FREE", "userTier": "GOLD" }
244+
```
245+
246+
**Text (both modes):**
247+
```
248+
This Actor is free to use. You are only charged for Apify platform usage.
249+
```
250+
251+
---
252+
253+
## Text output notes
254+
255+
- Prices render as `$<n>` — no trailing `USD`, no forced decimals, no thousands separator (`1000`, not `1,000`).
256+
- Complete mode with multi-tier: tiers listed inline, comma-separated.
257+
- Simplified mode: single price per event/unit, no tier labels inline. `pricingNote` is appended on its own line whenever the resolved tier is consistent across the Actor (including single-tier Actors).
258+
- Simplified `PAY_PER_EVENT` with more than 5 events: omit event descriptions in both structured output and text, then append `eventDescriptionsNote` on its own line in text output.
259+
260+
### Intentional text divergence from master
261+
262+
The text representation in complete mode is **reformatted** from master. The structured data is lossless (full tier matrix preserved), but the human-readable text is tightened for consistency with simplified mode:
263+
264+
- `PAY_PER_EVENT` preamble: `"This Actor is paid per event:"` (master had `"This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for the following events:"`).
265+
- Single-tier events drop the `(Tiered pricing: ...)` wrapper and render as flat `($X per event)`.
266+
- `PRICE_PER_DATASET_ITEM` drops the `(in this case named X)` custom-unit-name phrasing; unit name appears directly in the sentence (`"per 1000 pages"` vs `"per 1000 results (in this case named pages)"`).
267+
- `FLAT_PRICE_PER_MONTH` renders as `"costs $X per month"` instead of `"has a flat price of X USD per month"`.
268+
269+
Rationale: older phrasings read as boilerplate to LLMs and consume tokens without adding information the structured fields don't already carry. If you need the master phrasing verbatim, read the structured data and format it yourself.

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/fetch_actor_details_common.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import dedent from 'dedent';
22
import { z } from 'zod';
33

4-
import { ApifyClient } from '../../apify_client.js';
54
import { FAILURE_CATEGORY, HelperTools, TOOL_STATUS } from '../../const.js';
65
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
76
import type { HelperTool, InternalToolArgs, ToolInputSchema } from '../../types.js';
@@ -15,6 +14,7 @@ import {
1514
} from '../../utils/actor_details.js';
1615
import { compileSchema } from '../../utils/ajv.js';
1716
import { buildMCPResponse } from '../../utils/mcp.js';
17+
import { getUserInfoCached } from '../../utils/userid_cache.js';
1818
import { actorDetailsOutputSchema } from '../structured_output_schemas.js';
1919
import { fixActorNameInputAndLog } from './actor_tools_factory.js';
2020

@@ -224,13 +224,20 @@ export async function buildFetchActorDetailsResult(
224224
toolArgs: InternalToolArgs,
225225
route: HelperTools.ACTOR_GET_DETAILS | HelperTools.ACTOR_GET_DETAILS_INTERNAL,
226226
): Promise<ReturnType<typeof buildMCPResponse>> {
227-
const { args, apifyToken, apifyMcpServer, mcpSessionId } = toolArgs;
227+
const { args, apifyToken, apifyClient, apifyMcpServer, mcpSessionId } = toolArgs;
228228
const parsed = fetchActorDetailsToolArgsSchema.parse(args);
229229
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route });
230-
const apifyClient = new ApifyClient({ token: apifyToken });
231230

232231
const resolvedOutput = resolveOutputOptions(parsed.output);
233-
const details = await fetchActorDetails(apifyClient, actorName, buildCardOptions(resolvedOutput));
232+
// Skip the /users/me round-trip when pricing isn't rendered (e.g. inputSchema-only
233+
// or mcpTools-only requests). In that case `userTier` is only used to fill the
234+
// placeholder `{ model: 'FREE', userTier }` in the structured card, where it's never
235+
// read, so defaulting to 'FREE' is safe and saves a request.
236+
const userPlanTier = resolvedOutput.pricing
237+
? (await getUserInfoCached(apifyToken, apifyClient)).userPlanTier
238+
: 'FREE';
239+
const cardOptions = { ...buildCardOptions(resolvedOutput), userTier: userPlanTier };
240+
const details = await fetchActorDetails(apifyClient, actorName, cardOptions);
234241
if (!details) {
235242
return buildActorNotFoundResponse(actorName);
236243
}

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, simplifyPricingForUserTier: true };
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

0 commit comments

Comments
 (0)