akseljoonas commited on
Commit
dcd22bf
Β·
2 Parent(s): 9761d6bc4ac4e6

Deploy 2026-04-28

Browse files
agent/core/session_persistence.py CHANGED
@@ -98,6 +98,9 @@ class NoopSessionStore:
98
  async def refund_quota(self, *_: Any, **__: Any) -> None:
99
  return None
100
 
 
 
 
101
 
102
  class MongoSessionStore(NoopSessionStore):
103
  """MongoDB-backed session store."""
@@ -152,6 +155,7 @@ class MongoSessionStore(NoopSessionStore):
152
  [("session_id", 1), ("seq", 1)], unique=True
153
  )
154
  await self.db.session_trace_messages.create_index([("created_at", -1)])
 
155
 
156
  def _ready(self) -> bool:
157
  return bool(self.enabled and self.db is not None)
@@ -410,6 +414,63 @@ class MongoSessionStore(NoopSessionStore):
410
  {"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
411
  )
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
  _store: NoopSessionStore | MongoSessionStore | None = None
415
 
 
98
  async def refund_quota(self, *_: Any, **__: Any) -> None:
99
  return None
100
 
101
+ async def mark_pro_seen(self, *_: Any, **__: Any) -> dict[str, Any] | None:
102
+ return None
103
+
104
 
105
  class MongoSessionStore(NoopSessionStore):
106
  """MongoDB-backed session store."""
 
155
  [("session_id", 1), ("seq", 1)], unique=True
156
  )
157
  await self.db.session_trace_messages.create_index([("created_at", -1)])
158
+ await self.db.pro_users.create_index([("first_seen_pro_at", -1)])
159
 
160
  def _ready(self) -> bool:
161
  return bool(self.enabled and self.db is not None)
 
414
  {"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
415
  )
416
 
417
+ async def mark_pro_seen(
418
+ self, user_id: str, *, is_pro: bool
419
+ ) -> dict[str, Any] | None:
420
+ """Track per-user Pro state and detect free→Pro conversions.
421
+
422
+ Returns ``{"converted": True, "first_seen_at": ..."}`` exactly once
423
+ per user β€” the first time we see them as Pro after having recorded
424
+ them as non-Pro at least once. Otherwise returns ``None``.
425
+
426
+ Storing ``ever_non_pro`` lets us distinguish "user joined as Pro"
427
+ (no conversion) from "user upgraded" (conversion). The atomic
428
+ ``find_one_and_update`` on a guarded filter makes the conversion
429
+ emit at-most-once even under concurrent requests.
430
+ """
431
+ if not self._ready() or not user_id:
432
+ return None
433
+ now = _now()
434
+ set_fields: dict[str, Any] = {"last_seen_at": now, "is_pro": bool(is_pro)}
435
+ if not is_pro:
436
+ set_fields["ever_non_pro"] = True
437
+ try:
438
+ await self.db.pro_users.update_one(
439
+ {"_id": user_id},
440
+ {
441
+ "$setOnInsert": {"_id": user_id, "first_seen_at": now},
442
+ "$set": set_fields,
443
+ },
444
+ upsert=True,
445
+ )
446
+ except PyMongoError as e:
447
+ logger.debug("mark_pro_seen upsert failed for %s: %s", user_id, e)
448
+ return None
449
+
450
+ if not is_pro:
451
+ return None
452
+
453
+ try:
454
+ doc = await self.db.pro_users.find_one_and_update(
455
+ {
456
+ "_id": user_id,
457
+ "ever_non_pro": True,
458
+ "first_seen_pro_at": {"$exists": False},
459
+ },
460
+ {"$set": {"first_seen_pro_at": now}},
461
+ return_document=ReturnDocument.AFTER,
462
+ )
463
+ except PyMongoError as e:
464
+ logger.debug("mark_pro_seen conversion check failed for %s: %s", user_id, e)
465
+ return None
466
+
467
+ if not doc:
468
+ return None
469
+ return {
470
+ "converted": True,
471
+ "first_seen_at": (doc.get("first_seen_at") or now).isoformat(),
472
+ }
473
+
474
 
475
  _store: NoopSessionStore | MongoSessionStore | None = None
476
 
agent/core/telemetry.py CHANGED
@@ -277,6 +277,44 @@ async def record_pro_cta_click(
277
  logger.debug("record_pro_cta_click failed (non-fatal): %s", e)
278
 
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  # ── heartbeat ──────────────────────────────────────────────────────────────
281
 
282
  # Module-level reference set for fire-and-forget heartbeat tasks. asyncio only
 
277
  logger.debug("record_pro_cta_click failed (non-fatal): %s", e)
278
 
279
 
280
+ async def record_pro_conversion(
281
+ session: Any,
282
+ *,
283
+ first_seen_at: str | None = None,
284
+ ) -> None:
285
+ """Emit a ``pro_conversion`` event for a user we've previously observed
286
+ as non-Pro and now see as Pro for the first time. Detected upstream in
287
+ ``MongoSessionStore.mark_pro_seen``; fired into the user's first Pro
288
+ session so the rollup picks it up alongside other event-driven KPIs."""
289
+ from agent.core.session import Event
290
+ try:
291
+ await session.send_event(Event(
292
+ event_type="pro_conversion",
293
+ data={"first_seen_at": first_seen_at},
294
+ ))
295
+ except Exception as e:
296
+ logger.debug("record_pro_conversion failed (non-fatal): %s", e)
297
+
298
+
299
+ async def record_credits_topped_up(
300
+ session: Any,
301
+ *,
302
+ namespace: str | None = None,
303
+ ) -> None:
304
+ """Emit a ``credits_topped_up`` event when an hf_job submits successfully
305
+ in a session that previously hit ``jobs_access_blocked`` β€” i.e. the user
306
+ came back from the HF billing top-up flow and unblocked themselves.
307
+ Caller is responsible for firing this at most once per session."""
308
+ from agent.core.session import Event
309
+ try:
310
+ await session.send_event(Event(
311
+ event_type="credits_topped_up",
312
+ data={"namespace": namespace},
313
+ ))
314
+ except Exception as e:
315
+ logger.debug("record_credits_topped_up failed (non-fatal): %s", e)
316
+
317
+
318
  # ── heartbeat ──────────────────────────────────────────────────────────────
319
 
320
  # Module-level reference set for fire-and-forget heartbeat tasks. asyncio only
agent/tools/jobs_tool.py CHANGED
@@ -641,6 +641,23 @@ class HfJobsTool:
641
  {**args, "hardware_flavor": flavor, "timeout": timeout_str, "namespace": self.namespace},
642
  image=image, job_type=job_type,
643
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
 
645
  # Wait for completion and stream logs
646
  logger.info(f"{job_type} job started: {job.url}")
 
641
  {**args, "hardware_flavor": flavor, "timeout": timeout_str, "namespace": self.namespace},
642
  image=image, job_type=job_type,
643
  )
644
+ # Top-up signal: this submit succeeded after a prior billing
645
+ # block in the same session, and we haven't fired the event
646
+ # yet β€” the user came back from the HF billing flow.
647
+ events = self.session.logged_events
648
+ already_fired = any(
649
+ e.get("event_type") == "credits_topped_up" for e in events
650
+ )
651
+ if not already_fired:
652
+ blocked = any(
653
+ e.get("event_type") == "tool_state_change"
654
+ and (e.get("data") or {}).get("state") == "billing_required"
655
+ for e in events
656
+ )
657
+ if blocked:
658
+ await telemetry.record_credits_topped_up(
659
+ self.session, namespace=self.namespace,
660
+ )
661
 
662
  # Wait for completion and stream logs
663
  logger.info(f"{job_type} job started: {job.url}")
backend/routes/agent.py CHANGED
@@ -334,7 +334,10 @@ async def create_session(
334
 
335
  try:
336
  session_id = await session_manager.create_session(
337
- user_id=user["user_id"], hf_token=hf_token, model=model
 
 
 
338
  )
339
  except SessionCapacityError as e:
340
  raise HTTPException(status_code=503, detail=str(e))
@@ -370,7 +373,10 @@ async def restore_session_summary(
370
 
371
  try:
372
  session_id = await session_manager.create_session(
373
- user_id=user["user_id"], hf_token=hf_token, model=model
 
 
 
374
  )
375
  except SessionCapacityError as e:
376
  raise HTTPException(status_code=503, detail=str(e))
 
334
 
335
  try:
336
  session_id = await session_manager.create_session(
337
+ user_id=user["user_id"],
338
+ hf_token=hf_token,
339
+ model=model,
340
+ is_pro=user.get("plan") == "pro",
341
  )
342
  except SessionCapacityError as e:
343
  raise HTTPException(status_code=503, detail=str(e))
 
373
 
374
  try:
375
  session_id = await session_manager.create_session(
376
+ user_id=user["user_id"],
377
+ hf_token=hf_token,
378
+ model=model,
379
+ is_pro=user.get("plan") == "pro",
380
  )
381
  except SessionCapacityError as e:
382
  raise HTTPException(status_code=503, detail=str(e))
backend/session_manager.py CHANGED
@@ -465,6 +465,7 @@ class SessionManager:
465
  user_id: str = "dev",
466
  hf_token: str | None = None,
467
  model: str | None = None,
 
468
  ) -> str:
469
  """Create a new agent session and return its ID.
470
 
@@ -534,9 +535,36 @@ class SessionManager:
534
  )
535
  await self.persist_session_snapshot(agent_session, runtime_state="idle")
536
 
 
 
 
537
  logger.info(f"Created session {session_id} for user {user_id}")
538
  return session_id
539
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  async def seed_from_summary(self, session_id: str, messages: list[dict]) -> int:
541
  """Rehydrate a session from cached prior messages via summarization.
542
 
 
465
  user_id: str = "dev",
466
  hf_token: str | None = None,
467
  model: str | None = None,
468
+ is_pro: bool | None = None,
469
  ) -> str:
470
  """Create a new agent session and return its ID.
471
 
 
535
  )
536
  await self.persist_session_snapshot(agent_session, runtime_state="idle")
537
 
538
+ if is_pro is not None and user_id and user_id != "dev":
539
+ await self._track_pro_status(agent_session, is_pro=is_pro)
540
+
541
  logger.info(f"Created session {session_id} for user {user_id}")
542
  return session_id
543
 
544
+ async def _track_pro_status(self, agent_session: AgentSession, *, is_pro: bool) -> None:
545
+ """Update Mongo per-user Pro state and emit a one-shot conversion
546
+ event if the store reports a free→Pro transition. Best-effort: any
547
+ Mongo failure is swallowed so we never fail session creation on
548
+ telemetry."""
549
+ store = self._store()
550
+ if not getattr(store, "enabled", False):
551
+ return
552
+ try:
553
+ result = await store.mark_pro_seen(agent_session.user_id, is_pro=is_pro)
554
+ except Exception as e:
555
+ logger.debug("mark_pro_seen failed: %s", e)
556
+ return
557
+ if not result or not result.get("converted"):
558
+ return
559
+ try:
560
+ from agent.core import telemetry
561
+ await telemetry.record_pro_conversion(
562
+ agent_session.session,
563
+ first_seen_at=result.get("first_seen_at"),
564
+ )
565
+ except Exception as e:
566
+ logger.debug("record_pro_conversion failed: %s", e)
567
+
568
  async def seed_from_summary(self, session_id: str, messages: list[dict]) -> int:
569
  """Rehydrate a session from cached prior messages via summarization.
570
 
frontend/src/components/JobsUpgradeDialog.tsx CHANGED
@@ -1,23 +1,17 @@
1
- import {
2
- Box,
3
- Button,
4
- Dialog,
5
- DialogActions,
6
- DialogContent,
7
- DialogTitle,
8
- Typography,
9
- } from '@mui/material';
10
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
11
  import CreditCardIcon from '@mui/icons-material/CreditCard';
12
  import ReplayIcon from '@mui/icons-material/Replay';
 
13
 
14
  const HF_BILLING_URL = 'https://huggingface.co/settings/billing';
15
- const HF_ORANGE = '#FF9D00';
16
 
17
  interface JobsUpgradeDialogProps {
18
  open: boolean;
19
  message: string;
20
- /** True after the user clicked "Add credits" β€” switches the dialog into retry mode. */
 
 
21
  awaitingTopUp: boolean;
22
  onUpgrade: () => void;
23
  onRetry: () => void;
@@ -32,101 +26,140 @@ export default function JobsUpgradeDialog({
32
  onRetry,
33
  onClose,
34
  }: JobsUpgradeDialogProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  return (
36
- <Dialog
37
- open={open}
38
- onClose={onClose}
39
- slotProps={{
40
- backdrop: {
41
- sx: { backgroundColor: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)' },
42
- },
43
- }}
44
- PaperProps={{
45
- sx: {
46
- bgcolor: 'var(--panel)',
47
- border: '1px solid var(--border)',
48
- borderRadius: 'var(--radius-md)',
49
- boxShadow: '0 30px 80px rgba(0,0,0,0.45), var(--shadow-1)',
50
- maxWidth: 460,
51
- width: '100%',
52
- mx: 2,
53
- overflow: 'hidden',
54
- },
55
  }}
 
 
 
56
  >
57
  <Box
58
  sx={{
59
- height: 4,
60
- background: `linear-gradient(90deg, ${HF_ORANGE} 0%, #FFC560 50%, ${HF_ORANGE} 100%)`,
61
- }}
62
- />
63
-
64
- <DialogTitle
65
- sx={{
 
 
66
  display: 'flex',
 
67
  alignItems: 'center',
68
- gap: 1.25,
69
- color: 'var(--text)',
70
- fontWeight: 800,
71
- fontSize: '1.05rem',
72
- pt: 2.5,
73
- pb: 0.5,
74
- px: 3,
75
- letterSpacing: '-0.01em',
76
  }}
77
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  <Box
79
  sx={{
80
- width: 32,
81
- height: 32,
82
- borderRadius: '10px',
83
- bgcolor: 'rgba(255, 157, 0, 0.15)',
84
- color: HF_ORANGE,
 
85
  display: 'flex',
86
  alignItems: 'center',
87
  justifyContent: 'center',
 
88
  }}
89
  >
90
- <CreditCardIcon sx={{ fontSize: 18 }} />
91
  </Box>
92
- {awaitingTopUp ? 'Topped up?' : 'Top up to launch'}
93
- </DialogTitle>
94
- <DialogContent sx={{ px: 3, pt: 1.25, pb: 0 }}>
 
 
 
 
 
 
 
 
 
 
 
95
  <Typography
96
  sx={{
97
  color: 'var(--muted-text)',
98
  fontSize: '0.85rem',
99
  lineHeight: 1.6,
100
- mb: 1.5,
 
101
  }}
102
  >
103
  {awaitingTopUp
104
- ? "We'll auto-retry the job as soon as you switch back from the billing tab. Or hit the button below now."
105
  : message ||
106
- 'Hugging Face Jobs need credits on the namespace running them. Add some, then re-run the same job β€” the agent will pick it back up.'}
107
  </Typography>
108
- </DialogContent>
109
- <DialogActions sx={{ px: 3, pb: 2.5, pt: 2.5, gap: 1 }}>
110
- {awaitingTopUp ? (
111
- <Button
112
- onClick={onRetry}
113
- startIcon={<ReplayIcon sx={{ fontSize: 16 }} />}
114
- variant="contained"
115
- size="small"
116
- sx={{
117
- fontSize: '0.82rem',
118
- px: 2.5,
119
- bgcolor: HF_ORANGE,
120
- color: '#000',
121
- textTransform: 'none',
122
- fontWeight: 700,
123
- boxShadow: '0 6px 18px rgba(255, 157, 0, 0.35)',
124
- '&:hover': { bgcolor: '#FFB340', boxShadow: '0 8px 22px rgba(255, 157, 0, 0.45)' },
125
- }}
126
- >
127
- Retry now
128
- </Button>
129
- ) : (
130
  <Button
131
  component="a"
132
  href={HF_BILLING_URL}
@@ -135,35 +168,20 @@ export default function JobsUpgradeDialog({
135
  onClick={onUpgrade}
136
  startIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
137
  variant="contained"
138
- size="small"
139
- sx={{
140
- fontSize: '0.82rem',
141
- px: 2.5,
142
- bgcolor: HF_ORANGE,
143
- color: '#000',
144
- textTransform: 'none',
145
- fontWeight: 700,
146
- boxShadow: '0 6px 18px rgba(255, 157, 0, 0.35)',
147
- '&:hover': { bgcolor: '#FFB340', boxShadow: '0 8px 22px rgba(255, 157, 0, 0.45)' },
148
- }}
149
  >
150
  Add credits
151
  </Button>
152
- )}
153
- <Button
154
- onClick={onClose}
155
- size="small"
156
- sx={{
157
- color: 'var(--muted-text)',
158
- fontSize: '0.82rem',
159
- px: 2,
160
- textTransform: 'none',
161
- '&:hover': { bgcolor: 'var(--hover-bg)' },
162
- }}
163
- >
164
- Close
165
- </Button>
166
- </DialogActions>
167
- </Dialog>
168
  );
169
  }
 
1
+ import { Box, Button, Typography } from '@mui/material';
 
 
 
 
 
 
 
 
2
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
3
  import CreditCardIcon from '@mui/icons-material/CreditCard';
4
  import ReplayIcon from '@mui/icons-material/Replay';
5
+ import CloseIcon from '@mui/icons-material/Close';
6
 
7
  const HF_BILLING_URL = 'https://huggingface.co/settings/billing';
 
8
 
9
  interface JobsUpgradeDialogProps {
10
  open: boolean;
11
  message: string;
12
+ /** True after the user clicked "Add credits" β€” the visibility-change auto-retry
13
+ * in the parent uses this; it is unused inside the screen itself, which always
14
+ * shows both actions ("Add credits" and "I've added credits"). */
15
  awaitingTopUp: boolean;
16
  onUpgrade: () => void;
17
  onRetry: () => void;
 
26
  onRetry,
27
  onClose,
28
  }: JobsUpgradeDialogProps) {
29
+ if (!open) return null;
30
+
31
+ const primarySx = {
32
+ bgcolor: 'var(--text)',
33
+ color: 'var(--bg)',
34
+ fontWeight: 700,
35
+ fontSize: '0.85rem',
36
+ textTransform: 'none' as const,
37
+ px: 2.5,
38
+ py: 1,
39
+ borderRadius: '10px',
40
+ boxShadow: 'none',
41
+ '&:hover': { bgcolor: 'var(--text)', opacity: 0.9, boxShadow: 'none' },
42
+ };
43
+
44
+ const secondarySx = {
45
+ bgcolor: 'transparent',
46
+ color: 'var(--text)',
47
+ fontWeight: 600,
48
+ fontSize: '0.85rem',
49
+ textTransform: 'none' as const,
50
+ px: 2.5,
51
+ py: 1,
52
+ borderRadius: '10px',
53
+ border: '1px solid var(--border-hover)',
54
+ '&:hover': { bgcolor: 'var(--hover-bg)', borderColor: 'var(--border-hover)' },
55
+ };
56
+
57
  return (
58
+ <Box
59
+ sx={{
60
+ position: 'fixed',
61
+ inset: 0,
62
+ zIndex: 1300,
63
+ display: 'flex',
64
+ alignItems: 'center',
65
+ justifyContent: 'center',
66
+ backgroundColor: 'rgba(0,0,0,0.55)',
67
+ backdropFilter: 'blur(8px)',
68
+ px: 2,
 
 
 
 
 
 
 
 
69
  }}
70
+ role="dialog"
71
+ aria-modal="true"
72
+ aria-labelledby="jobs-billing-title"
73
  >
74
  <Box
75
  sx={{
76
+ position: 'relative',
77
+ width: '100%',
78
+ maxWidth: 480,
79
+ bgcolor: 'var(--panel)',
80
+ border: '1px solid var(--border)',
81
+ borderRadius: 'var(--radius-md)',
82
+ boxShadow: 'var(--shadow-1)',
83
+ px: 4,
84
+ py: 4,
85
  display: 'flex',
86
+ flexDirection: 'column',
87
  alignItems: 'center',
88
+ textAlign: 'center',
 
 
 
 
 
 
 
89
  }}
90
  >
91
+ <Button
92
+ onClick={onClose}
93
+ aria-label="Close"
94
+ sx={{
95
+ position: 'absolute',
96
+ top: 10,
97
+ right: 10,
98
+ minWidth: 0,
99
+ width: 28,
100
+ height: 28,
101
+ borderRadius: '8px',
102
+ color: 'var(--muted-text)',
103
+ '&:hover': { bgcolor: 'var(--hover-bg)', color: 'var(--text)' },
104
+ }}
105
+ >
106
+ <CloseIcon sx={{ fontSize: 16 }} />
107
+ </Button>
108
+
109
  <Box
110
  sx={{
111
+ width: 44,
112
+ height: 44,
113
+ borderRadius: '12px',
114
+ bgcolor: 'var(--surface)',
115
+ border: '1px solid var(--border)',
116
+ color: 'var(--muted-text)',
117
  display: 'flex',
118
  alignItems: 'center',
119
  justifyContent: 'center',
120
+ mb: 2,
121
  }}
122
  >
123
+ <CreditCardIcon sx={{ fontSize: 22 }} />
124
  </Box>
125
+
126
+ <Typography
127
+ id="jobs-billing-title"
128
+ sx={{
129
+ color: 'var(--text)',
130
+ fontWeight: 700,
131
+ fontSize: '1.05rem',
132
+ letterSpacing: '-0.01em',
133
+ mb: 1,
134
+ }}
135
+ >
136
+ {awaitingTopUp ? 'Resume when you’re ready' : 'Add credits to launch this job'}
137
+ </Typography>
138
+
139
  <Typography
140
  sx={{
141
  color: 'var(--muted-text)',
142
  fontSize: '0.85rem',
143
  lineHeight: 1.6,
144
+ mb: 3,
145
+ maxWidth: 380,
146
  }}
147
  >
148
  {awaitingTopUp
149
+ ? 'Once your top-up is through, click below to resume β€” the agent will pick the run back up where it left off.'
150
  : message ||
151
+ 'Hugging Face Jobs need credits on the namespace running them. Add some, then resume β€” the agent waits here in the meantime.'}
152
  </Typography>
153
+
154
+ <Box
155
+ sx={{
156
+ display: 'flex',
157
+ flexDirection: { xs: 'column', sm: 'row' },
158
+ gap: 1.25,
159
+ width: '100%',
160
+ justifyContent: 'center',
161
+ }}
162
+ >
 
 
 
 
 
 
 
 
 
 
 
 
163
  <Button
164
  component="a"
165
  href={HF_BILLING_URL}
 
168
  onClick={onUpgrade}
169
  startIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
170
  variant="contained"
171
+ sx={primarySx}
 
 
 
 
 
 
 
 
 
 
172
  >
173
  Add credits
174
  </Button>
175
+ <Button
176
+ onClick={onRetry}
177
+ startIcon={<ReplayIcon sx={{ fontSize: 16 }} />}
178
+ variant="outlined"
179
+ sx={secondarySx}
180
+ >
181
+ I’ve added credits
182
+ </Button>
183
+ </Box>
184
+ </Box>
185
+ </Box>
 
 
 
 
 
186
  );
187
  }
scripts/build_kpis.py CHANGED
@@ -224,7 +224,7 @@ def _session_metrics(session: dict) -> dict:
224
  "failures": 0, "regenerate_sessions": 0,
225
  "thumbs_up": 0, "thumbs_down": 0,
226
  "hf_jobs_submitted": 0, "hf_jobs_succeeded": 0, "hf_jobs_blocked": 0,
227
- "pro_cta_clicks": 0,
228
  "sandboxes_created": 0, "sandboxes_cpu": 0, "sandboxes_gpu": 0,
229
  "first_tool_s": -1,
230
  }
@@ -251,6 +251,8 @@ def _session_metrics(session: dict) -> dict:
251
  sandboxes_gpu = 0
252
  jobs_blocked = 0
253
  pro_cta_clicks = 0
 
 
254
  pro_cta_by_source: dict[str, int] = defaultdict(int)
255
  # Per-tool counters from tool_call events. Counted off tool_call (which
256
  # carries data["tool"]) rather than tool_output (which only carries
@@ -321,6 +323,12 @@ def _session_metrics(session: dict) -> dict:
321
  source = str(data.get("source") or "unknown")
322
  pro_cta_by_source[source] += 1
323
 
 
 
 
 
 
 
324
  elif et == "sandbox_create":
325
  sandboxes_created += 1
326
  hardware = (data.get("hardware") or "").lower()
@@ -347,6 +355,8 @@ def _session_metrics(session: dict) -> dict:
347
  out["sandboxes_gpu"] = sandboxes_gpu
348
  out["hf_jobs_blocked"] = jobs_blocked
349
  out["pro_cta_clicks"] = pro_cta_clicks
 
 
350
  out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1
351
  out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor)
352
  out["_pro_cta_by_source"] = dict(pro_cta_by_source)
@@ -462,6 +472,8 @@ def _aggregate(per_session: list[dict]) -> dict:
462
  "sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)),
463
  "hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)),
464
  "pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)),
 
 
465
  "gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True),
466
  # Research KPIs β€” answer "is the agent reaching for research?".
467
  "research_calls": research_calls_total,
 
224
  "failures": 0, "regenerate_sessions": 0,
225
  "thumbs_up": 0, "thumbs_down": 0,
226
  "hf_jobs_submitted": 0, "hf_jobs_succeeded": 0, "hf_jobs_blocked": 0,
227
+ "pro_cta_clicks": 0, "pro_conversions": 0, "credits_topped_up": 0,
228
  "sandboxes_created": 0, "sandboxes_cpu": 0, "sandboxes_gpu": 0,
229
  "first_tool_s": -1,
230
  }
 
251
  sandboxes_gpu = 0
252
  jobs_blocked = 0
253
  pro_cta_clicks = 0
254
+ pro_conversions = 0
255
+ credits_topped_up = 0
256
  pro_cta_by_source: dict[str, int] = defaultdict(int)
257
  # Per-tool counters from tool_call events. Counted off tool_call (which
258
  # carries data["tool"]) rather than tool_output (which only carries
 
323
  source = str(data.get("source") or "unknown")
324
  pro_cta_by_source[source] += 1
325
 
326
+ elif et == "pro_conversion":
327
+ pro_conversions += 1
328
+
329
+ elif et == "credits_topped_up":
330
+ credits_topped_up += 1
331
+
332
  elif et == "sandbox_create":
333
  sandboxes_created += 1
334
  hardware = (data.get("hardware") or "").lower()
 
355
  out["sandboxes_gpu"] = sandboxes_gpu
356
  out["hf_jobs_blocked"] = jobs_blocked
357
  out["pro_cta_clicks"] = pro_cta_clicks
358
+ out["pro_conversions"] = pro_conversions
359
+ out["credits_topped_up"] = credits_topped_up
360
  out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1
361
  out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor)
362
  out["_pro_cta_by_source"] = dict(pro_cta_by_source)
 
472
  "sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)),
473
  "hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)),
474
  "pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)),
475
+ "pro_conversions": int(sum(s.get("pro_conversions", 0) for s in per_session)),
476
+ "credits_topped_up": int(sum(s.get("credits_topped_up", 0) for s in per_session)),
477
  "gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True),
478
  # Research KPIs β€” answer "is the agent reaching for research?".
479
  "research_calls": research_calls_total,
tests/unit/test_build_kpis.py CHANGED
@@ -104,6 +104,32 @@ def test_hf_job_blocked_and_pro_clicks_are_counted():
104
  }
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def test_feedback_counts():
108
  mod = _load()
109
  events = [
 
104
  }
105
 
106
 
107
+ def test_pro_conversions_and_credits_topped_up_per_session():
108
+ mod = _load()
109
+ events = [
110
+ _ev("pro_conversion", {"first_seen_at": "2026-04-20T10:00:00"}),
111
+ _ev("credits_topped_up", {"namespace": "smolagents"}),
112
+ _ev("credits_topped_up", {"namespace": "smolagents"}),
113
+ ]
114
+ m = mod._session_metrics(_session(events))
115
+ assert m["pro_conversions"] == 1
116
+ assert m["credits_topped_up"] == 2
117
+
118
+
119
+ def test_aggregate_sums_pro_conversions_and_credits_topped_up():
120
+ mod = _load()
121
+ s1 = mod._session_metrics(_session([
122
+ _ev("pro_conversion", {}),
123
+ ], user_id="u1"))
124
+ s2 = mod._session_metrics(_session([
125
+ _ev("credits_topped_up", {"namespace": "ns"}),
126
+ ], user_id="u2"))
127
+ s3 = mod._session_metrics(_session([], user_id="u3"))
128
+ row = mod._aggregate([s1, s2, s3])
129
+ assert row["pro_conversions"] == 1
130
+ assert row["credits_topped_up"] == 1
131
+
132
+
133
  def test_feedback_counts():
134
  mod = _load()
135
  events = [
tests/unit/test_session_persistence.py CHANGED
@@ -2,7 +2,11 @@
2
 
3
  import pytest
4
 
5
- from agent.core.session_persistence import NoopSessionStore, _safe_message_doc
 
 
 
 
6
 
7
 
8
  @pytest.mark.asyncio
@@ -29,3 +33,97 @@ def test_unsafe_message_payload_is_replaced_with_marker():
29
 
30
  assert marker["role"] == "tool"
31
  assert marker["ml_intern_persistence_error"] == "message_too_large_or_invalid"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import pytest
4
 
5
+ from agent.core.session_persistence import (
6
+ MongoSessionStore,
7
+ NoopSessionStore,
8
+ _safe_message_doc,
9
+ )
10
 
11
 
12
  @pytest.mark.asyncio
 
33
 
34
  assert marker["role"] == "tool"
35
  assert marker["ml_intern_persistence_error"] == "message_too_large_or_invalid"
36
+
37
+
38
+ # ── mark_pro_seen ─────────────────────────────────────────────────────────
39
+
40
+
41
+ class _FakeProUsers:
42
+ """In-memory stand-in for the ``pro_users`` collection.
43
+
44
+ Supports just enough of the Motor API to exercise ``mark_pro_seen``:
45
+ ``update_one`` with ``$setOnInsert`` + ``$set`` + ``upsert=True``, and
46
+ ``find_one_and_update`` with the guarded filter the conversion check uses.
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self.docs: dict[str, dict] = {}
51
+
52
+ async def update_one(self, filt, update, upsert=False):
53
+ _id = filt["_id"]
54
+ doc = self.docs.get(_id)
55
+ if doc is None and upsert:
56
+ doc = dict(update.get("$setOnInsert") or {})
57
+ self.docs[_id] = doc
58
+ if doc is None:
59
+ return
60
+ for k, v in (update.get("$set") or {}).items():
61
+ doc[k] = v
62
+
63
+ async def find_one_and_update(self, filt, update, return_document=None):
64
+ _id = filt["_id"]
65
+ doc = self.docs.get(_id)
66
+ if doc is None:
67
+ return None
68
+ # Guard checks the conversion test uses: ever_non_pro=True AND
69
+ # first_seen_pro_at missing.
70
+ for k, v in filt.items():
71
+ if k == "_id":
72
+ continue
73
+ if isinstance(v, dict) and "$exists" in v:
74
+ if v["$exists"] and k not in doc:
75
+ return None
76
+ if not v["$exists"] and k in doc:
77
+ return None
78
+ elif doc.get(k) != v:
79
+ return None
80
+ for k, v in (update.get("$set") or {}).items():
81
+ doc[k] = v
82
+ return dict(doc)
83
+
84
+
85
+ class _FakeDB:
86
+ def __init__(self) -> None:
87
+ self.pro_users = _FakeProUsers()
88
+
89
+
90
+ def _store_with_fake_db() -> MongoSessionStore:
91
+ s = MongoSessionStore.__new__(MongoSessionStore)
92
+ s.enabled = True
93
+ s.db = _FakeDB()
94
+ return s
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_mark_pro_seen_returns_none_when_unknown_user_starts_pro():
99
+ """Joining as Pro shouldn't count as a conversion."""
100
+ store = _store_with_fake_db()
101
+ assert await store.mark_pro_seen("u-new-pro", is_pro=True) is None
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_mark_pro_seen_emits_conversion_after_seeing_user_as_free():
106
+ store = _store_with_fake_db()
107
+ assert await store.mark_pro_seen("u1", is_pro=False) is None
108
+ result = await store.mark_pro_seen("u1", is_pro=True)
109
+ assert result is not None
110
+ assert result["converted"] is True
111
+ assert isinstance(result["first_seen_at"], str)
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_mark_pro_seen_only_fires_conversion_once():
116
+ """Re-checking a converted user must not re-emit the event."""
117
+ store = _store_with_fake_db()
118
+ await store.mark_pro_seen("u1", is_pro=False)
119
+ first = await store.mark_pro_seen("u1", is_pro=True)
120
+ assert first is not None and first["converted"] is True
121
+ second = await store.mark_pro_seen("u1", is_pro=True)
122
+ assert second is None
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_noop_store_mark_pro_seen_returns_none():
127
+ store = NoopSessionStore()
128
+ assert await store.mark_pro_seen("u1", is_pro=True) is None
129
+ assert await store.mark_pro_seen("u1", is_pro=False) is None