Aksel Joonas Reedi commited on
Commit
bed4e38
Β·
unverified Β·
1 Parent(s): d9d9785

Bill HF Jobs by namespace credits, drop Pro and org-join gating (#172)

Browse files

* Bill HF Jobs by namespace credits, drop Pro and org-join gating

- Drop the Pro/Team subscription pre-check on hf_jobs. Any namespace the
caller belongs to is eligible; HF returns a billing error at job
creation time if the wallet is empty, which the agent surfaces as a
"top up to launch" dialog pointing at /settings/billing.
- Remove the "Join ML Agent Explorers" onboarding step. The org-join
hook, /auth/org-membership endpoint, and the OAuth orgIds suggestion
are gone.
- Rework JobsUpgradeDialog: namespace picker shows all eligible
accounts/orgs with avatars + select rings; billing mode opens the HF
billing page; "Skip this tool call" stays available for both.
- Backend pre-flight only forces the picker when the caller has
multiple eligible namespaces; single-namespace users default to their
personal account silently.
- jobs_tool now catches HfHubHTTPError, detects billing failures by
pattern + 402 status, emits a tool_state_change(state=billing_required)
so the frontend can pop the credits dialog without retrying server-side.

* Drop namespace picker, auto-retry hf_jobs after top-up

Org membership doesn't grant billing rights, so the multi-namespace
picker was misleading in prod. Always default to the user's personal
namespace; the agent can still take a namespace from the user's chat
("run on team-a") via tool args.

When hf_jobs hits a billing error, the dialog now switches into a
"Topped up?" state after the user clicks Add credits. Returning to the
tab auto-sends a retry message to the agent; a Retry now button covers
the case where the user keeps both windows side-by-side.

agent/core/hf_access.py CHANGED
@@ -1,9 +1,16 @@
1
- """Helpers for Hugging Face account / org access decisions."""
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import asyncio
6
  import os
 
7
  from dataclasses import dataclass
8
  from typing import Any
9
 
@@ -14,35 +21,34 @@ OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface
14
 
15
  @dataclass(frozen=True)
16
  class JobsAccess:
17
- """Jobs entitlement derived from whoami-v2."""
18
 
19
  username: str | None
20
- plan: str
21
- personal_can_run_jobs: bool
22
- paid_org_names: list[str]
23
  eligible_namespaces: list[str]
24
  default_namespace: str | None
25
  access_known: bool = True
26
 
27
- @property
28
- def can_run_jobs(self) -> bool:
29
- return bool(self.default_namespace)
30
-
31
 
32
  class JobsAccessError(Exception):
33
- """Structured jobs access error for upgrade / namespace gating."""
 
 
 
 
 
 
 
34
 
35
  def __init__(
36
  self,
37
  message: str,
38
  *,
39
  access: JobsAccess | None = None,
40
- upgrade_required: bool = False,
41
  namespace_required: bool = False,
42
  ) -> None:
43
  super().__init__(message)
44
  self.access = access
45
- self.upgrade_required = upgrade_required
46
  self.namespace_required = namespace_required
47
 
48
 
@@ -54,65 +60,38 @@ def _extract_username(whoami: dict[str, Any]) -> str | None:
54
  return None
55
 
56
 
57
- def _normalize_personal_plan(whoami: dict[str, Any]) -> str:
58
- # OAuth whoami responses set `type: "user"` and surface Pro status only via
59
- # the `isPro` boolean. Check the boolean first so a generic `type` value
60
- # doesn't shadow it β€” otherwise Pro OAuth users get classified as free and
61
- # blocked from running Jobs (smolagents/ml-intern Space discussion #21).
62
- if whoami.get("isPro") is True or whoami.get("is_pro") is True:
63
- return "pro"
64
 
65
- plan_str = ""
66
- for key in ("plan", "type", "accountType"):
67
- value = whoami.get(key)
68
- if isinstance(value, str) and value:
69
- plan_str = value.lower()
70
- break
71
-
72
- if any(tag in plan_str for tag in ("pro", "enterprise", "team")):
73
- return "pro"
74
- return "free"
75
-
76
-
77
- def _paid_org_names(whoami: dict[str, Any]) -> list[str]:
78
  names: list[str] = []
79
  orgs = whoami.get("orgs") or []
80
  if not isinstance(orgs, list):
81
  return names
82
-
83
  for org in orgs:
84
  if not isinstance(org, dict):
85
  continue
86
  name = org.get("name")
87
- if not isinstance(name, str) or not name:
88
- continue
89
- org_plan = str(org.get("plan") or org.get("type") or "").lower()
90
- if any(tag in org_plan for tag in ("pro", "enterprise", "team")):
91
  names.append(name)
92
  return sorted(set(names))
93
 
94
 
95
  def jobs_access_from_whoami(whoami: dict[str, Any]) -> JobsAccess:
96
  username = _extract_username(whoami)
97
- personal_plan = _normalize_personal_plan(whoami)
98
- paid_orgs = _paid_org_names(whoami)
99
- personal_can_run = personal_plan == "pro"
100
-
101
- eligible_namespaces: list[str] = []
102
- if personal_can_run and username:
103
- eligible_namespaces.append(username)
104
- eligible_namespaces.extend(paid_orgs)
105
-
106
- plan = "pro" if personal_can_run else ("org" if paid_orgs else "free")
107
- default_namespace = username if personal_can_run and username else None
108
-
109
  return JobsAccess(
110
  username=username,
111
- plan=plan,
112
- personal_can_run_jobs=personal_can_run,
113
- paid_org_names=paid_orgs,
114
- eligible_namespaces=eligible_namespaces,
115
- default_namespace=default_namespace,
116
  )
117
 
118
 
@@ -154,26 +133,18 @@ async def resolve_jobs_namespace(
154
  if requested_namespace in access.eligible_namespaces:
155
  return requested_namespace, access
156
  raise JobsAccessError(
157
- f"You can only run jobs under your own Pro account or a paid org you belong to. "
158
  f"Allowed namespaces: {', '.join(access.eligible_namespaces) or '(none)'}",
159
  access=access,
160
  )
161
  if access.default_namespace:
162
  return access.default_namespace, access
163
- if access.paid_org_names:
164
- raise JobsAccessError(
165
- "Choose which paid organization should own this job run.",
166
- access=access,
167
- namespace_required=True,
168
- )
169
  raise JobsAccessError(
170
- "Hugging Face Jobs are available only to Pro users and Team or Enterprise organizations. "
171
- "Upgrade to Pro, or run the job under a paid org you belong to.",
172
  access=access,
173
- upgrade_required=True,
174
  )
175
 
176
- # Fallback: whoami-v2 unavailable. Do not block the call pre-emptively.
177
  from huggingface_hub import HfApi
178
 
179
  username = None
@@ -183,3 +154,19 @@ async def resolve_jobs_namespace(
183
  if not username:
184
  raise JobsAccessError("No HF token available to resolve a jobs namespace.")
185
  return requested_namespace or username, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for Hugging Face account / org access decisions.
2
+
3
+ HF Jobs are gated by *credits*, not by HF Pro subscriptions. Any user who
4
+ has credits β€” on their personal account or on an org they belong to β€” can
5
+ launch jobs under that namespace. The picker UI lets the caller choose
6
+ which wallet to bill.
7
+ """
8
 
9
  from __future__ import annotations
10
 
11
  import asyncio
12
  import os
13
+ import re
14
  from dataclasses import dataclass
15
  from typing import Any
16
 
 
21
 
22
  @dataclass(frozen=True)
23
  class JobsAccess:
24
+ """Namespaces the caller may bill HF Jobs to."""
25
 
26
  username: str | None
27
+ org_names: list[str]
 
 
28
  eligible_namespaces: list[str]
29
  default_namespace: str | None
30
  access_known: bool = True
31
 
 
 
 
 
32
 
33
  class JobsAccessError(Exception):
34
+ """Structured jobs-namespace error.
35
+
36
+ ``namespace_required`` fires when the caller belongs to more than one
37
+ eligible namespace and the UI must prompt them to pick one. There is no
38
+ longer an ``upgrade_required`` state β€” Pro is irrelevant; HF Jobs are
39
+ gated on per-wallet credits, surfaced separately when the API returns
40
+ a billing error at job-creation time.
41
+ """
42
 
43
  def __init__(
44
  self,
45
  message: str,
46
  *,
47
  access: JobsAccess | None = None,
 
48
  namespace_required: bool = False,
49
  ) -> None:
50
  super().__init__(message)
51
  self.access = access
 
52
  self.namespace_required = namespace_required
53
 
54
 
 
60
  return None
61
 
62
 
63
+ def _org_names(whoami: dict[str, Any]) -> list[str]:
64
+ """All orgs the caller belongs to.
 
 
 
 
 
65
 
66
+ Plan/tier is ignored β€” credits live on the namespace itself, so any
67
+ org the user belongs to can host a job as long as it has credits.
68
+ """
 
 
 
 
 
 
 
 
 
 
69
  names: list[str] = []
70
  orgs = whoami.get("orgs") or []
71
  if not isinstance(orgs, list):
72
  return names
 
73
  for org in orgs:
74
  if not isinstance(org, dict):
75
  continue
76
  name = org.get("name")
77
+ if isinstance(name, str) and name:
 
 
 
78
  names.append(name)
79
  return sorted(set(names))
80
 
81
 
82
  def jobs_access_from_whoami(whoami: dict[str, Any]) -> JobsAccess:
83
  username = _extract_username(whoami)
84
+ org_names = _org_names(whoami)
85
+ eligible: list[str] = []
86
+ if username:
87
+ eligible.append(username)
88
+ eligible.extend(org_names)
89
+ default = username if username else (org_names[0] if org_names else None)
 
 
 
 
 
 
90
  return JobsAccess(
91
  username=username,
92
+ org_names=org_names,
93
+ eligible_namespaces=eligible,
94
+ default_namespace=default,
 
 
95
  )
96
 
97
 
 
133
  if requested_namespace in access.eligible_namespaces:
134
  return requested_namespace, access
135
  raise JobsAccessError(
136
+ f"You can only run jobs under your own account or an org you belong to. "
137
  f"Allowed namespaces: {', '.join(access.eligible_namespaces) or '(none)'}",
138
  access=access,
139
  )
140
  if access.default_namespace:
141
  return access.default_namespace, access
 
 
 
 
 
 
142
  raise JobsAccessError(
143
+ "Couldn't resolve a Hugging Face namespace for this token.",
 
144
  access=access,
 
145
  )
146
 
147
+ # Fallback: whoami-v2 unavailable. Don't block the call pre-emptively.
148
  from huggingface_hub import HfApi
149
 
150
  username = None
 
154
  if not username:
155
  raise JobsAccessError("No HF token available to resolve a jobs namespace.")
156
  return requested_namespace or username, None
157
+
158
+
159
+ _BILLING_PATTERNS = re.compile(
160
+ r"\b(insufficient[_\s-]?credits?|out\s+of\s+credits?|payment\s+required|"
161
+ r"billing|no\s+credits?|add\s+credits?|requires?\s+credits?)\b",
162
+ re.IGNORECASE,
163
+ )
164
+
165
+
166
+ def is_billing_error(message: str) -> bool:
167
+ """True if an HF API error message looks like an out-of-credits / billing error."""
168
+ if not message:
169
+ return False
170
+ if "402" in message:
171
+ return True
172
+ return bool(_BILLING_PATTERNS.search(message))
agent/tools/jobs_tool.py CHANGED
@@ -17,7 +17,7 @@ import httpx
17
  from huggingface_hub import HfApi
18
  from huggingface_hub.utils import HfHubHTTPError
19
 
20
- from agent.core.hf_access import JobsAccessError, resolve_jobs_namespace
21
  from agent.core.session import Event
22
  from agent.tools.trackio_seed import ensure_trackio_dashboard
23
  from agent.tools.types import ToolResult
@@ -572,16 +572,45 @@ class HfJobsTool:
572
  if trackio_project:
573
  env_dict["TRACKIO_PROJECT"] = trackio_project
574
 
575
- job = await _async_call(
576
- self.api.run_job,
577
- image=image,
578
- command=command,
579
- env=env_dict,
580
- secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
581
- flavor=flavor,
582
- timeout=timeout_str,
583
- namespace=self.namespace,
584
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
 
586
  # Track job ID for cancellation on interrupt
587
  if self.session:
@@ -1129,9 +1158,9 @@ HF_JOBS_TOOL_SPEC = {
1129
  "namespace": {
1130
  "type": "string",
1131
  "description": (
1132
- "Optional namespace to run the job under. Must be your own Pro account "
1133
- "or a paid org you belong to. If omitted, the tool prefers your personal "
1134
- "account when eligible, otherwise the first eligible paid org."
1135
  ),
1136
  },
1137
  "job_id": {
 
17
  from huggingface_hub import HfApi
18
  from huggingface_hub.utils import HfHubHTTPError
19
 
20
+ from agent.core.hf_access import JobsAccessError, is_billing_error, resolve_jobs_namespace
21
  from agent.core.session import Event
22
  from agent.tools.trackio_seed import ensure_trackio_dashboard
23
  from agent.tools.types import ToolResult
 
572
  if trackio_project:
573
  env_dict["TRACKIO_PROJECT"] = trackio_project
574
 
575
+ try:
576
+ job = await _async_call(
577
+ self.api.run_job,
578
+ image=image,
579
+ command=command,
580
+ env=env_dict,
581
+ secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
582
+ flavor=flavor,
583
+ timeout=timeout_str,
584
+ namespace=self.namespace,
585
+ )
586
+ except HfHubHTTPError as e:
587
+ if is_billing_error(str(e)):
588
+ if self.session and self.tool_call_id:
589
+ await self.session.send_event(
590
+ Event(
591
+ event_type="tool_state_change",
592
+ data={
593
+ "tool_call_id": self.tool_call_id,
594
+ "tool": "hf_jobs",
595
+ "state": "billing_required",
596
+ "namespace": self.namespace,
597
+ },
598
+ )
599
+ )
600
+ return {
601
+ "formatted": (
602
+ f"Hugging Face Jobs rejected this run because the "
603
+ f"namespace `{self.namespace}` has no available credits. "
604
+ "Tell the user to add credits at "
605
+ "https://huggingface.co/settings/billing β€” once topped up, "
606
+ "re-run this same job. (Switching namespaces is fine if "
607
+ "another wallet has credits.)"
608
+ ),
609
+ "totalResults": 0,
610
+ "resultsShared": 0,
611
+ "isError": True,
612
+ }
613
+ raise
614
 
615
  # Track job ID for cancellation on interrupt
616
  if self.session:
 
1158
  "namespace": {
1159
  "type": "string",
1160
  "description": (
1161
+ "Optional namespace to run the job under. Must be the caller's own "
1162
+ "account or an org they belong to. If omitted, defaults to the "
1163
+ "caller's personal account. Credits are billed against this namespace."
1164
  ),
1165
  },
1166
  "job_id": {
backend/routes/agent.py CHANGED
@@ -139,108 +139,6 @@ async def _enforce_claude_quota(
139
  await session_manager.persist_session_snapshot(agent_session)
140
 
141
 
142
- async def _enforce_jobs_access_for_approvals(
143
- user: dict[str, Any],
144
- agent_session: AgentSession,
145
- approvals: list[dict[str, Any]],
146
- ) -> None:
147
- """Block approved hf_jobs tool calls when the user has no eligible jobs namespace."""
148
- pending = agent_session.session.pending_approval or {}
149
- tool_calls = pending.get("tool_calls") or []
150
- if not tool_calls:
151
- return
152
-
153
- approved_ids = {
154
- a.get("tool_call_id")
155
- for a in approvals
156
- if a.get("approved")
157
- }
158
- if not approved_ids:
159
- return
160
-
161
- hf_job_ids = [
162
- tc.id for tc in tool_calls
163
- if tc.id in approved_ids and tc.function.name == "hf_jobs"
164
- ]
165
- if not hf_job_ids:
166
- return
167
-
168
- token = agent_session.hf_token or agent_session.session.hf_token
169
- if not token:
170
- return
171
-
172
- access = await get_jobs_access(token)
173
- if access is None:
174
- return
175
-
176
- approval_map = {a.get("tool_call_id"): a for a in approvals}
177
- if access.personal_can_run_jobs:
178
- return
179
-
180
- if access.paid_org_names:
181
- invalid_namespace = [
182
- tool_call_id
183
- for tool_call_id in hf_job_ids
184
- if (
185
- approval_map.get(tool_call_id, {}).get("namespace")
186
- and approval_map.get(tool_call_id, {}).get("namespace") not in access.paid_org_names
187
- )
188
- ]
189
- if invalid_namespace:
190
- raise HTTPException(
191
- status_code=400,
192
- detail={
193
- "error": "hf_jobs_invalid_namespace",
194
- "message": (
195
- "The selected jobs namespace is not one of your eligible paid organizations. "
196
- f"Allowed namespaces: {', '.join(access.paid_org_names)}"
197
- ),
198
- "plan": user.get("plan", "free"),
199
- "tool_call_ids": invalid_namespace,
200
- "eligible_namespaces": access.paid_org_names,
201
- },
202
- )
203
- missing_namespace = [
204
- tool_call_id
205
- for tool_call_id in hf_job_ids
206
- if not approval_map.get(tool_call_id, {}).get("namespace")
207
- ]
208
- if missing_namespace:
209
- raise HTTPException(
210
- status_code=409,
211
- detail={
212
- "error": "hf_jobs_namespace_required",
213
- "message": "Choose which paid organization should own this job run.",
214
- "plan": user.get("plan", "free"),
215
- "tool_call_ids": missing_namespace,
216
- "eligible_namespaces": access.paid_org_names,
217
- },
218
- )
219
- return
220
-
221
- from agent.core import telemetry
222
- await telemetry.record_jobs_access_blocked(
223
- agent_session.session,
224
- tool_call_ids=hf_job_ids,
225
- plan=user.get("plan", "free"),
226
- eligible_namespaces=access.eligible_namespaces,
227
- )
228
-
229
- raise HTTPException(
230
- status_code=402,
231
- detail={
232
- "error": "hf_jobs_upgrade_required",
233
- "message": (
234
- "Hugging Face Jobs are available only to Pro users and Team or Enterprise organizations. "
235
- "Upgrade to Pro, or decline the job tool call so the agent can choose another path."
236
- ),
237
- "plan": user.get("plan", "free"),
238
- "tool_call_ids": hf_job_ids,
239
- "eligible_namespaces": access.eligible_namespaces,
240
- },
241
- )
242
-
243
-
244
  async def _check_session_access(
245
  session_id: str,
246
  user: dict[str, Any],
@@ -573,15 +471,19 @@ async def get_user_quota(user: dict = Depends(get_current_user)) -> dict:
573
 
574
  @router.get("/user/jobs-access")
575
  async def get_jobs_access_info(request: Request, user: dict = Depends(get_current_user)) -> dict:
576
- """Return whether the current token can run HF Jobs and under which namespaces."""
 
 
 
 
 
577
  token = resolve_hf_request_token(request)
578
 
579
  access = await get_jobs_access(token or "")
580
  return {
581
- "plan": user.get("plan", "free"),
582
- "can_run_jobs": bool(access and (access.personal_can_run_jobs or access.paid_org_names)),
583
  "eligible_namespaces": access.eligible_namespaces if access else [],
584
  "default_namespace": access.default_namespace if access else None,
 
585
  }
586
 
587
 
@@ -633,7 +535,6 @@ async def submit_approval(
633
  }
634
  for a in request.approvals
635
  ]
636
- await _enforce_jobs_access_for_approvals(user, agent_session, approvals)
637
  success = await session_manager.submit_approval(request.session_id, approvals)
638
  if not success:
639
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -685,7 +586,6 @@ async def chat_sse(
685
  }
686
  for a in approvals
687
  ]
688
- await _enforce_jobs_access_for_approvals(user, agent_session, formatted)
689
  success = await session_manager.submit_approval(session_id, formatted)
690
  elif text is not None:
691
  success = await session_manager.submit_user_input(session_id, text)
 
139
  await session_manager.persist_session_snapshot(agent_session)
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  async def _check_session_access(
143
  session_id: str,
144
  user: dict[str, Any],
 
471
 
472
  @router.get("/user/jobs-access")
473
  async def get_jobs_access_info(request: Request, user: dict = Depends(get_current_user)) -> dict:
474
+ """Return the namespaces the current token can run HF Jobs under.
475
+
476
+ Credits are enforced by the HF API at job-creation time, not here β€”
477
+ the response only describes which wallets the caller is allowed to
478
+ pick from. Pro is irrelevant.
479
+ """
480
  token = resolve_hf_request_token(request)
481
 
482
  access = await get_jobs_access(token or "")
483
  return {
 
 
484
  "eligible_namespaces": access.eligible_namespaces if access else [],
485
  "default_namespace": access.default_namespace if access else None,
486
+ "billing_url": "https://huggingface.co/settings/billing",
487
  }
488
 
489
 
 
535
  }
536
  for a in request.approvals
537
  ]
 
538
  success = await session_manager.submit_approval(request.session_id, approvals)
539
  if not success:
540
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
586
  }
587
  for a in approvals
588
  ]
 
589
  success = await session_manager.submit_approval(session_id, formatted)
590
  elif text is not None:
591
  success = await session_manager.submit_user_input(session_id, text)
backend/routes/auth.py CHANGED
@@ -10,7 +10,7 @@ import time
10
  from urllib.parse import urlencode
11
 
12
  import httpx
13
- from dependencies import AUTH_ENABLED, check_org_membership, get_current_user
14
  from fastapi import APIRouter, Depends, HTTPException, Request
15
  from fastapi.responses import RedirectResponse
16
 
@@ -63,16 +63,15 @@ async def oauth_login(request: Request) -> RedirectResponse:
63
  "expires_at": time.time() + _OAUTH_STATE_TTL,
64
  }
65
 
66
- # Build authorization URL
 
 
67
  params = {
68
  "client_id": OAUTH_CLIENT_ID,
69
  "redirect_uri": get_redirect_uri(request),
70
  "scope": "openid profile read-repos write-repos contribute-repos manage-repos inference-api jobs write-discussions",
71
  "response_type": "code",
72
  "state": state,
73
- "orgIds": os.environ.get(
74
- "HF_OAUTH_ORG_ID", "698dbf55845d85df163175f1"
75
- ), # ml-agent-explorers
76
  }
77
  auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
78
 
@@ -171,18 +170,3 @@ async def get_me(user: dict = Depends(get_current_user)) -> dict:
171
  return user
172
 
173
 
174
- ORG_NAME = "ml-agent-explorers"
175
-
176
-
177
- @router.get("/org-membership")
178
- async def org_membership(
179
- request: Request, user: dict = Depends(get_current_user)
180
- ) -> dict:
181
- """Check if the authenticated user belongs to the ml-agent-explorers org."""
182
- if not AUTH_ENABLED:
183
- return {"is_member": True}
184
- token = request.cookies.get("hf_access_token") or ""
185
- if not token:
186
- return {"is_member": False}
187
- is_member = await check_org_membership(token, ORG_NAME)
188
- return {"is_member": is_member}
 
10
  from urllib.parse import urlencode
11
 
12
  import httpx
13
+ from dependencies import AUTH_ENABLED, get_current_user
14
  from fastapi import APIRouter, Depends, HTTPException, Request
15
  from fastapi.responses import RedirectResponse
16
 
 
63
  "expires_at": time.time() + _OAUTH_STATE_TTL,
64
  }
65
 
66
+ # Build authorization URL. We no longer suggest a default `orgIds` β€”
67
+ # users no longer need to join the ML Agent Explorers org to use the
68
+ # app, and HF Jobs are billed per-namespace via credits.
69
  params = {
70
  "client_id": OAUTH_CLIENT_ID,
71
  "redirect_uri": get_redirect_uri(request),
72
  "scope": "openid profile read-repos write-repos contribute-repos manage-repos inference-api jobs write-discussions",
73
  "response_type": "code",
74
  "state": state,
 
 
 
75
  }
76
  auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
77
 
 
170
  return user
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -66,8 +66,6 @@ interface ChatInputProps {
66
  sessionId?: string;
67
  onSend: (text: string) => void;
68
  onStop?: () => void;
69
- onDeclineBlockedJobs?: () => Promise<boolean>;
70
- onContinueBlockedJobsWithNamespace?: (namespace: string) => Promise<boolean>;
71
  isProcessing?: boolean;
72
  disabled?: boolean;
73
  placeholder?: string;
@@ -76,7 +74,7 @@ interface ChatInputProps {
76
  const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath);
77
  const firstFreeModel = () => MODEL_OPTIONS.find(m => !isClaudeModel(m)) ?? MODEL_OPTIONS[0];
78
 
79
- export default function ChatInput({ sessionId, onSend, onStop, onDeclineBlockedJobs, onContinueBlockedJobsWithNamespace, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
80
  const [input, setInput] = useState('');
81
  const inputRef = useRef<HTMLTextAreaElement>(null);
82
  const [selectedModelId, setSelectedModelId] = useState<string>(MODEL_OPTIONS[0].id);
@@ -91,6 +89,7 @@ export default function ChatInput({ sessionId, onSend, onStop, onDeclineBlockedJ
91
  const setClaudeQuotaExhausted = useAgentStore((s) => s.setClaudeQuotaExhausted);
92
  const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired);
93
  const setJobsUpgradeRequired = useAgentStore((s) => s.setJobsUpgradeRequired);
 
94
  const lastSentRef = useRef<string>('');
95
 
96
  // Model is per-session: fetch this tab's current model every time the
@@ -216,29 +215,44 @@ export default function ChatInput({ sessionId, onSend, onStop, onDeclineBlockedJ
216
 
217
  const handleJobsUpgradeClose = useCallback(() => {
218
  setJobsUpgradeRequired(null);
 
219
  }, [setJobsUpgradeRequired]);
220
 
221
  const handleJobsUpgradeClick = useCallback(async () => {
 
222
  if (!sessionId || !jobsUpgradeRequired) return;
223
  try {
224
  await apiFetch(`/api/pro-click/${sessionId}`, {
225
  method: 'POST',
226
- body: JSON.stringify({ source: 'hf_jobs_upgrade_dialog', target: 'pro_pricing' }),
227
  });
228
  } catch {
229
  /* tracking is best-effort */
230
  }
231
  }, [sessionId, jobsUpgradeRequired]);
232
 
233
- const handleDeclineBlockedJobs = useCallback(async () => {
234
- if (!onDeclineBlockedJobs) return;
235
- await onDeclineBlockedJobs();
236
- }, [onDeclineBlockedJobs]);
 
 
 
 
 
237
 
238
- const handleContinueBlockedJobsWithNamespace = useCallback(async (namespace: string) => {
239
- if (!onContinueBlockedJobsWithNamespace) return;
240
- await onContinueBlockedJobsWithNamespace(namespace);
241
- }, [onContinueBlockedJobsWithNamespace]);
 
 
 
 
 
 
 
 
242
 
243
  // Hide the chip until the user has actually burned quota β€” an unused
244
  // Opus session shouldn't populate a counter.
@@ -482,13 +496,11 @@ export default function ChatInput({ sessionId, onSend, onStop, onDeclineBlockedJ
482
  />
483
  <JobsUpgradeDialog
484
  open={!!jobsUpgradeRequired}
485
- mode={jobsUpgradeRequired?.mode || 'upgrade'}
486
  message={jobsUpgradeRequired?.message || ''}
487
- eligibleNamespaces={jobsUpgradeRequired?.eligibleNamespaces || []}
488
  onClose={handleJobsUpgradeClose}
489
  onUpgrade={handleJobsUpgradeClick}
490
- onDecline={handleDeclineBlockedJobs}
491
- onContinueWithNamespace={handleContinueBlockedJobsWithNamespace}
492
  />
493
  </Box>
494
  </Box>
 
66
  sessionId?: string;
67
  onSend: (text: string) => void;
68
  onStop?: () => void;
 
 
69
  isProcessing?: boolean;
70
  disabled?: boolean;
71
  placeholder?: string;
 
74
  const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath);
75
  const firstFreeModel = () => MODEL_OPTIONS.find(m => !isClaudeModel(m)) ?? MODEL_OPTIONS[0];
76
 
77
+ export default function ChatInput({ sessionId, onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
78
  const [input, setInput] = useState('');
79
  const inputRef = useRef<HTMLTextAreaElement>(null);
80
  const [selectedModelId, setSelectedModelId] = useState<string>(MODEL_OPTIONS[0].id);
 
89
  const setClaudeQuotaExhausted = useAgentStore((s) => s.setClaudeQuotaExhausted);
90
  const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired);
91
  const setJobsUpgradeRequired = useAgentStore((s) => s.setJobsUpgradeRequired);
92
+ const [awaitingTopUp, setAwaitingTopUp] = useState(false);
93
  const lastSentRef = useRef<string>('');
94
 
95
  // Model is per-session: fetch this tab's current model every time the
 
215
 
216
  const handleJobsUpgradeClose = useCallback(() => {
217
  setJobsUpgradeRequired(null);
218
+ setAwaitingTopUp(false);
219
  }, [setJobsUpgradeRequired]);
220
 
221
  const handleJobsUpgradeClick = useCallback(async () => {
222
+ setAwaitingTopUp(true);
223
  if (!sessionId || !jobsUpgradeRequired) return;
224
  try {
225
  await apiFetch(`/api/pro-click/${sessionId}`, {
226
  method: 'POST',
227
+ body: JSON.stringify({ source: 'hf_jobs_billing_dialog', target: 'hf_billing' }),
228
  });
229
  } catch {
230
  /* tracking is best-effort */
231
  }
232
  }, [sessionId, jobsUpgradeRequired]);
233
 
234
+ const handleJobsRetry = useCallback(() => {
235
+ const namespace = jobsUpgradeRequired?.namespace;
236
+ setJobsUpgradeRequired(null);
237
+ setAwaitingTopUp(false);
238
+ const msg = namespace
239
+ ? `I just added credits to the \`${namespace}\` namespace. Please retry the previous job.`
240
+ : "I just added credits. Please retry the previous job.";
241
+ onSend(msg);
242
+ }, [jobsUpgradeRequired, setJobsUpgradeRequired, onSend]);
243
 
244
+ // Auto-retry when the user comes back to this tab after clicking "Add credits".
245
+ // Browsers fire visibilitychange when the tab regains focus from a sibling tab.
246
+ useEffect(() => {
247
+ if (!awaitingTopUp || !jobsUpgradeRequired) return;
248
+ const onVisible = () => {
249
+ if (document.visibilityState === 'visible') {
250
+ handleJobsRetry();
251
+ }
252
+ };
253
+ document.addEventListener('visibilitychange', onVisible);
254
+ return () => document.removeEventListener('visibilitychange', onVisible);
255
+ }, [awaitingTopUp, jobsUpgradeRequired, handleJobsRetry]);
256
 
257
  // Hide the chip until the user has actually burned quota β€” an unused
258
  // Opus session shouldn't populate a counter.
 
496
  />
497
  <JobsUpgradeDialog
498
  open={!!jobsUpgradeRequired}
 
499
  message={jobsUpgradeRequired?.message || ''}
500
+ awaitingTopUp={awaitingTopUp}
501
  onClose={handleJobsUpgradeClose}
502
  onUpgrade={handleJobsUpgradeClick}
503
+ onRetry={handleJobsRetry}
 
504
  />
505
  </Box>
506
  </Box>
frontend/src/components/JobsUpgradeDialog.tsx CHANGED
@@ -1,188 +1,157 @@
1
- import { useEffect, useState } from 'react';
2
  import {
3
  Box,
4
  Button,
5
  Dialog,
6
  DialogActions,
7
  DialogContent,
8
- DialogContentText,
9
  DialogTitle,
10
- FormControl,
11
- MenuItem,
12
- Select,
13
  Typography,
14
  } from '@mui/material';
 
 
 
15
 
16
- const HF_PRICING_URL = 'https://huggingface.co/pricing';
 
17
 
18
  interface JobsUpgradeDialogProps {
19
  open: boolean;
20
- mode: 'upgrade' | 'namespace';
21
  message: string;
22
- eligibleNamespaces: string[];
 
23
  onUpgrade: () => void;
24
- onDecline: () => void;
25
  onClose: () => void;
26
- onContinueWithNamespace: (namespace: string) => void;
27
  }
28
 
29
  export default function JobsUpgradeDialog({
30
  open,
31
- mode,
32
  message,
33
- eligibleNamespaces,
34
  onUpgrade,
35
- onDecline,
36
  onClose,
37
- onContinueWithNamespace,
38
  }: JobsUpgradeDialogProps) {
39
- const [selectedNamespace, setSelectedNamespace] = useState(() => eligibleNamespaces[0] || '');
40
-
41
- useEffect(() => {
42
- if (!open) return;
43
- setSelectedNamespace(eligibleNamespaces[0] || '');
44
- }, [open, eligibleNamespaces]);
45
-
46
- const isNamespace = mode === 'namespace';
47
- const title = isNamespace ? 'Run jobs as' : 'Jobs need Pro or a paid org';
48
-
49
- const body = isNamespace
50
- ? "Pick which paid organization should pay for and own this job. We'll use the same one for the rest of this browser."
51
- : message;
52
-
53
  return (
54
  <Dialog
55
  open={open}
56
  onClose={onClose}
57
  slotProps={{
58
- backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' } },
 
 
59
  }}
60
  PaperProps={{
61
  sx: {
62
  bgcolor: 'var(--panel)',
63
  border: '1px solid var(--border)',
64
  borderRadius: 'var(--radius-md)',
65
- boxShadow: 'var(--shadow-1)',
66
  maxWidth: 460,
 
67
  mx: 2,
 
68
  },
69
  }}
70
  >
 
 
 
 
 
 
 
71
  <DialogTitle
72
- sx={{ color: 'var(--text)', fontWeight: 700, fontSize: '1rem', pt: 2.5, pb: 0, px: 3 }}
 
 
 
 
 
 
 
 
 
 
 
73
  >
74
- {title}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </DialogTitle>
76
  <DialogContent sx={{ px: 3, pt: 1.25, pb: 0 }}>
77
- <DialogContentText
78
- sx={{ color: 'var(--muted-text)', fontSize: '0.85rem', lineHeight: 1.6 }}
 
 
 
 
 
79
  >
80
- {body}
81
- </DialogContentText>
82
-
83
- {isNamespace ? (
84
- <FormControl fullWidth size="small" sx={{ mt: 2 }}>
85
- <Select
86
- value={selectedNamespace}
87
- displayEmpty
88
- onChange={(e) => setSelectedNamespace(String(e.target.value))}
89
- sx={{
90
- bgcolor: 'var(--composer-bg)',
91
- color: 'var(--text)',
92
- fontSize: '0.88rem',
93
- fontWeight: 600,
94
- '& .MuiOutlinedInput-notchedOutline': { borderColor: 'var(--border)' },
95
- '&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'var(--border)' },
96
- '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
97
- borderColor: 'var(--accent-yellow)',
98
- borderWidth: 1,
99
- },
100
- '& .MuiSelect-icon': { color: 'var(--muted-text)' },
101
- }}
102
- MenuProps={{
103
- PaperProps: {
104
- sx: {
105
- bgcolor: 'var(--panel)',
106
- border: '1px solid var(--border)',
107
- borderRadius: '8px',
108
- mt: 0.5,
109
- },
110
- },
111
- }}
112
- >
113
- {eligibleNamespaces.map((namespace) => (
114
- <MenuItem
115
- key={namespace}
116
- value={namespace}
117
- sx={{
118
- fontSize: '0.88rem',
119
- color: 'var(--text)',
120
- '&.Mui-selected': { bgcolor: 'rgba(255,255,255,0.05)' },
121
- }}
122
- >
123
- {namespace}
124
- </MenuItem>
125
- ))}
126
- </Select>
127
- </FormControl>
128
- ) : (
129
- eligibleNamespaces.length > 0 && (
130
- <Box sx={{ mt: 1.5 }}>
131
- <Typography
132
- variant="caption"
133
- sx={{ color: 'var(--muted-text)', fontSize: '0.78rem', lineHeight: 1.55 }}
134
- >
135
- Eligible namespaces: {eligibleNamespaces.join(', ')}
136
- </Typography>
137
- </Box>
138
- )
139
- )}
140
  </DialogContent>
141
  <DialogActions sx={{ px: 3, pb: 2.5, pt: 2.5, gap: 1 }}>
142
- {isNamespace ? (
143
  <Button
144
- onClick={() => onContinueWithNamespace(selectedNamespace)}
145
- disabled={!selectedNamespace}
146
  variant="contained"
147
  size="small"
148
  sx={{
149
  fontSize: '0.82rem',
150
  px: 2.5,
151
- bgcolor: 'var(--accent-yellow)',
152
  color: '#000',
153
  textTransform: 'none',
154
  fontWeight: 700,
155
- boxShadow: 'none',
156
- '&:hover': { bgcolor: '#FFB340', boxShadow: 'none' },
157
  }}
158
  >
159
- Continue
160
  </Button>
161
  ) : (
162
  <Button
163
  component="a"
164
- href={HF_PRICING_URL}
165
  target="_blank"
166
  rel="noopener noreferrer"
167
  onClick={onUpgrade}
 
168
  variant="contained"
169
  size="small"
170
  sx={{
171
  fontSize: '0.82rem',
172
  px: 2.5,
173
- bgcolor: 'var(--accent-yellow)',
174
  color: '#000',
175
  textTransform: 'none',
176
  fontWeight: 700,
177
- boxShadow: 'none',
178
- '&:hover': { bgcolor: '#FFB340', boxShadow: 'none' },
179
  }}
180
  >
181
- Upgrade to Pro
182
  </Button>
183
  )}
184
  <Button
185
- onClick={onDecline}
186
  size="small"
187
  sx={{
188
  color: 'var(--muted-text)',
@@ -192,7 +161,7 @@ export default function JobsUpgradeDialog({
192
  '&:hover': { bgcolor: 'var(--hover-bg)' },
193
  }}
194
  >
195
- {isNamespace ? 'Skip this tool call' : 'Decline tool call'}
196
  </Button>
197
  </DialogActions>
198
  </Dialog>
 
 
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;
24
  onClose: () => void;
 
25
  }
26
 
27
  export default function JobsUpgradeDialog({
28
  open,
 
29
  message,
30
+ awaitingTopUp,
31
  onUpgrade,
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}
133
  target="_blank"
134
  rel="noopener noreferrer"
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)',
 
161
  '&:hover': { bgcolor: 'var(--hover-bg)' },
162
  }}
163
  >
164
+ Close
165
  </Button>
166
  </DialogActions>
167
  </Dialog>
frontend/src/components/SessionChat.tsx CHANGED
@@ -26,7 +26,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
26
  const { updateSessionTitle, sessions } = useSessionStore();
27
  const isExpired = sessions.find((s) => s.id === sessionId)?.expired === true;
28
 
29
- const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools, declineBlockedJobs, continueBlockedJobsWithNamespace } = useAgentChat({
30
  sessionId,
31
  isActive,
32
  onReady: () => logger.log(`Session ${sessionId} ready`),
@@ -114,8 +114,6 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
114
  sessionId={sessionId}
115
  onSend={handleSendMessage}
116
  onStop={handleStop}
117
- onDeclineBlockedJobs={declineBlockedJobs}
118
- onContinueBlockedJobsWithNamespace={continueBlockedJobsWithNamespace}
119
  isProcessing={busy}
120
  disabled={!isConnected || activityStatus.type === 'waiting-approval'}
121
  placeholder={
 
26
  const { updateSessionTitle, sessions } = useSessionStore();
27
  const isExpired = sessions.find((s) => s.id === sessionId)?.expired === true;
28
 
29
+ const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools } = useAgentChat({
30
  sessionId,
31
  isActive,
32
  onReady: () => logger.log(`Session ${sessionId} ready`),
 
114
  sessionId={sessionId}
115
  onSend={handleSendMessage}
116
  onStop={handleStop}
 
 
117
  isProcessing={busy}
118
  disabled={!isConnected || activityStatus.type === 'waiting-approval'}
119
  placeholder={
frontend/src/components/WelcomeScreen/WelcomeScreen.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
2
  import {
3
  Box,
4
  Typography,
@@ -8,18 +8,14 @@ import {
8
  } from '@mui/material';
9
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
10
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
11
- import GroupAddIcon from '@mui/icons-material/GroupAdd';
12
  import LoginIcon from '@mui/icons-material/Login';
13
  import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
14
  import { useSessionStore } from '@/store/sessionStore';
15
  import { useAgentStore } from '@/store/agentStore';
16
  import { apiFetch } from '@/utils/api';
17
  import { isInIframe, triggerLogin } from '@/hooks/useAuth';
18
- import { useOrgMembership } from '@/hooks/useOrgMembership';
19
 
20
  const HF_ORANGE = '#FF9D00';
21
- const ORG_JOIN_URL =
22
- 'https://huggingface.co/organizations/ml-agent-explorers/share/GzPMJUivoFPlfkvFtIqEouZKSytatKQSZT';
23
 
24
  // ---------------------------------------------------------------------------
25
  // ChecklistStep sub-component
@@ -192,48 +188,6 @@ export default function WelcomeScreen() {
192
  const isAuthenticated = !!user?.authenticated;
193
  const isDevUser = user?.username === 'dev';
194
 
195
- // Iframe: localStorage-based org tracking (no auth token available)
196
- const [iframeOrgJoined, setIframeOrgJoined] = useState(() => {
197
- try { return localStorage.getItem('hf-agent-org-joined') === '1'; } catch { return false; }
198
- });
199
- const joinLinkOpened = useRef(false);
200
-
201
- // Auto-advance when user returns from org join link (iframe only)
202
- useEffect(() => {
203
- if (!inIframe) return;
204
- const handleVisibility = () => {
205
- if (document.visibilityState !== 'visible' || !joinLinkOpened.current) return;
206
- joinLinkOpened.current = false;
207
- try { localStorage.setItem('hf-agent-org-joined', '1'); } catch { /* ignore */ }
208
- setIframeOrgJoined(true);
209
- };
210
- document.addEventListener('visibilitychange', handleVisibility);
211
- return () => document.removeEventListener('visibilitychange', handleVisibility);
212
- }, [inIframe]);
213
-
214
- const isOrgMember = inIframe ? iframeOrgJoined : !!user?.orgMember;
215
-
216
- // Poll for org membership once authenticated (skipped in dev mode and iframe)
217
- const popupRef = useOrgMembership(isAuthenticated && !isDevUser && !inIframe && !isOrgMember);
218
-
219
- // ---- Actions ----
220
-
221
- const handleJoinOrg = useCallback(() => {
222
- if (inIframe) {
223
- // Iframe: open link, track via visibilitychange + localStorage
224
- joinLinkOpened.current = true;
225
- window.open(ORG_JOIN_URL, '_blank', 'noopener,noreferrer');
226
- return;
227
- }
228
- // Direct: open as popup, auto-close via polling
229
- const popup = window.open(ORG_JOIN_URL, 'hf-org-join', 'noopener');
230
- if (popup) {
231
- popupRef.current = popup;
232
- } else {
233
- window.open(ORG_JOIN_URL, '_blank', 'noopener,noreferrer');
234
- }
235
- }, [popupRef, inIframe]);
236
-
237
  const handleStartSession = useCallback(async () => {
238
  if (isCreating) return;
239
  setIsCreating(true);
@@ -268,8 +222,7 @@ export default function WelcomeScreen() {
268
  // ---- Step status helpers ----
269
 
270
  const signInStatus: StepStatus = isAuthenticated ? 'completed' : 'active';
271
- const joinOrgStatus: StepStatus = isOrgMember ? 'completed' : isAuthenticated ? 'active' : 'locked';
272
- const startStatus: StepStatus = isAuthenticated && isOrgMember ? 'active' : 'locked';
273
 
274
  // Space URL for iframe "Open ML Intern" step
275
  const spaceHost =
@@ -372,31 +325,19 @@ export default function WelcomeScreen() {
372
  isLast
373
  />
374
  ) : inIframe ? (
375
- /* Iframe: 2 steps */
376
- <>
377
- <ChecklistStep
378
- stepNumber={1}
379
- title="Join ML Agent Explorers"
380
- description="Get free access to GPUs, inference APIs, and Hub resources."
381
- status={isOrgMember ? 'completed' : 'active'}
382
- actionLabel="Join Organization"
383
- actionIcon={<GroupAddIcon sx={{ fontSize: 16 }} />}
384
- onAction={handleJoinOrg}
385
- />
386
- <ChecklistStep
387
- stepNumber={2}
388
- title="Open ML Intern"
389
- description="Open the agent in a full browser tab to get started."
390
- status={isOrgMember ? 'active' : 'locked'}
391
- lockedReason="Join the organization first."
392
- actionLabel="Open ML Intern"
393
- actionIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
394
- actionHref={spaceHost}
395
- isLast
396
- />
397
- </>
398
  ) : (
399
- /* Direct access: 3 steps */
400
  <>
401
  <ChecklistStep
402
  stepNumber={1}
@@ -409,20 +350,10 @@ export default function WelcomeScreen() {
409
  />
410
  <ChecklistStep
411
  stepNumber={2}
412
- title="Join ML Agent Explorers"
413
- description="Get free access to GPUs, inference APIs, and Hub resources."
414
- status={joinOrgStatus}
415
- lockedReason="Sign in first to continue."
416
- actionLabel="Join Organization"
417
- actionIcon={<GroupAddIcon sx={{ fontSize: 16 }} />}
418
- onAction={handleJoinOrg}
419
- />
420
- <ChecklistStep
421
- stepNumber={3}
422
  title="Start Session"
423
  description="Launch an AI agent session for ML engineering."
424
  status={startStatus}
425
- lockedReason="Complete the steps above to continue."
426
  actionLabel="Start Session"
427
  actionIcon={<RocketLaunchIcon sx={{ fontSize: 16 }} />}
428
  onAction={handleStartSession}
@@ -433,16 +364,6 @@ export default function WelcomeScreen() {
433
  )}
434
  </Box>
435
 
436
- {/* Polling hint when waiting for org join */}
437
- {isAuthenticated && !isOrgMember && !isDevUser && !inIframe && (
438
- <Typography
439
- variant="caption"
440
- sx={{ mt: 2, color: 'var(--muted-text)', fontSize: '0.75rem', textAlign: 'center' }}
441
- >
442
- This page updates automatically when you join the organization.
443
- </Typography>
444
- )}
445
-
446
  {/* Error */}
447
  {error && (
448
  <Alert
 
1
+ import { useState, useCallback, type ReactNode } from 'react';
2
  import {
3
  Box,
4
  Typography,
 
8
  } from '@mui/material';
9
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
10
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
 
11
  import LoginIcon from '@mui/icons-material/Login';
12
  import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
13
  import { useSessionStore } from '@/store/sessionStore';
14
  import { useAgentStore } from '@/store/agentStore';
15
  import { apiFetch } from '@/utils/api';
16
  import { isInIframe, triggerLogin } from '@/hooks/useAuth';
 
17
 
18
  const HF_ORANGE = '#FF9D00';
 
 
19
 
20
  // ---------------------------------------------------------------------------
21
  // ChecklistStep sub-component
 
188
  const isAuthenticated = !!user?.authenticated;
189
  const isDevUser = user?.username === 'dev';
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const handleStartSession = useCallback(async () => {
192
  if (isCreating) return;
193
  setIsCreating(true);
 
222
  // ---- Step status helpers ----
223
 
224
  const signInStatus: StepStatus = isAuthenticated ? 'completed' : 'active';
225
+ const startStatus: StepStatus = isAuthenticated ? 'active' : 'locked';
 
226
 
227
  // Space URL for iframe "Open ML Intern" step
228
  const spaceHost =
 
325
  isLast
326
  />
327
  ) : inIframe ? (
328
+ /* Iframe: open in a full tab */
329
+ <ChecklistStep
330
+ stepNumber={1}
331
+ title="Open ML Intern"
332
+ description="Open the agent in a full browser tab to get started."
333
+ status="active"
334
+ actionLabel="Open ML Intern"
335
+ actionIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
336
+ actionHref={spaceHost}
337
+ isLast
338
+ />
 
 
 
 
 
 
 
 
 
 
 
 
339
  ) : (
340
+ /* Direct access: sign in β†’ start */
341
  <>
342
  <ChecklistStep
343
  stepNumber={1}
 
350
  />
351
  <ChecklistStep
352
  stepNumber={2}
 
 
 
 
 
 
 
 
 
 
353
  title="Start Session"
354
  description="Launch an AI agent session for ML engineering."
355
  status={startStatus}
356
+ lockedReason="Sign in first to continue."
357
  actionLabel="Start Session"
358
  actionIcon={<RocketLaunchIcon sx={{ fontSize: 16 }} />}
359
  onAction={handleStartSession}
 
364
  )}
365
  </Box>
366
 
 
 
 
 
 
 
 
 
 
 
367
  {/* Error */}
368
  {error && (
369
  <Alert
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -330,49 +330,6 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
330
  messages: UIMessage[];
331
  }>({ setMessages: null, messages: [] });
332
 
333
- const hydrateFromBackend = useCallback(async () => {
334
- try {
335
- const [msgsRes, infoRes] = await Promise.all([
336
- apiFetch(`/api/session/${sessionId}/messages`),
337
- apiFetch(`/api/session/${sessionId}`),
338
- ]);
339
- if (!msgsRes.ok) return null;
340
- const data = await msgsRes.json();
341
- if (!Array.isArray(data) || data.length === 0) return null;
342
-
343
- saveBackendMessages(sessionId, data);
344
-
345
- let pendingIds: Set<string> | undefined;
346
- let info: Record<string, unknown> | null = null;
347
- if (infoRes.ok) {
348
- info = await infoRes.json();
349
- const pendingApproval = info?.pending_approval;
350
- if (pendingApproval && Array.isArray(pendingApproval)) {
351
- pendingIds = new Set(
352
- pendingApproval.map((t: { tool_call_id: string }) => t.tool_call_id),
353
- );
354
- if (pendingIds.size > 0) {
355
- setNeedsAttention(sessionId, true);
356
- }
357
- }
358
- }
359
-
360
- const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
361
- if (uiMsgs.length > 0) {
362
- chatActionsRef.current.setMessages?.(uiMsgs);
363
- saveMessages(sessionId, uiMsgs);
364
- }
365
-
366
- if (pendingIds && pendingIds.size > 0) {
367
- updateSession(sessionId, { activityStatus: { type: 'waiting-approval' }, isProcessing: false });
368
- }
369
-
370
- return { data, pendingIds, info };
371
- } catch {
372
- return null;
373
- }
374
- }, [sessionId, setNeedsAttention, updateSession]);
375
-
376
  // -- useChat from Vercel AI SDK -----------------------------------------
377
  const chat = useChat({
378
  id: sessionId,
@@ -397,83 +354,6 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
397
  }
398
  return;
399
  }
400
- if (error.message === 'HF_JOBS_UPGRADE_REQUIRED') {
401
- const typed = error as Error & {
402
- detail?: Record<string, unknown>;
403
- approvals?: Array<{
404
- tool_call_id: string;
405
- approved: boolean;
406
- feedback?: string | null;
407
- edited_script?: string | null;
408
- }>;
409
- };
410
- void hydrateFromBackend();
411
- if (isActiveRef.current) {
412
- useAgentStore.getState().setJobsUpgradeRequired({
413
- approvals: typed.approvals || [],
414
- toolCallIds: (typed.detail?.tool_call_ids as string[]) || [],
415
- message: String(
416
- typed.detail?.message
417
- || 'Hugging Face Jobs are available only to Pro users and Team or Enterprise organizations.',
418
- ),
419
- eligibleNamespaces: (typed.detail?.eligible_namespaces as string[]) || [],
420
- plan: ((typed.detail?.plan as 'free' | 'pro' | 'org') || 'free'),
421
- mode: 'upgrade',
422
- });
423
- }
424
- return;
425
- }
426
- if (error.message === 'HF_JOBS_NAMESPACE_REQUIRED') {
427
- const typed = error as Error & {
428
- detail?: Record<string, unknown>;
429
- approvals?: Array<{
430
- tool_call_id: string;
431
- approved: boolean;
432
- feedback?: string | null;
433
- edited_script?: string | null;
434
- namespace?: string | null;
435
- }>;
436
- };
437
- void hydrateFromBackend();
438
- if (isActiveRef.current) {
439
- useAgentStore.getState().setJobsUpgradeRequired({
440
- approvals: typed.approvals || [],
441
- toolCallIds: (typed.detail?.tool_call_ids as string[]) || [],
442
- message: String(typed.detail?.message || 'Choose which organization should own this job run.'),
443
- eligibleNamespaces: (typed.detail?.eligible_namespaces as string[]) || [],
444
- plan: ((typed.detail?.plan as 'free' | 'pro' | 'org') || 'free'),
445
- mode: 'namespace',
446
- });
447
- }
448
- return;
449
- }
450
- if (error.message === 'HF_JOBS_INVALID_NAMESPACE') {
451
- // Saved preference is no longer one of the user's eligible namespaces
452
- // (e.g. they left the org). Clear it and reopen the picker.
453
- const typed = error as Error & {
454
- detail?: Record<string, unknown>;
455
- approvals?: Array<{
456
- tool_call_id: string;
457
- approved: boolean;
458
- feedback?: string | null;
459
- edited_script?: string | null;
460
- namespace?: string | null;
461
- }>;
462
- };
463
- useAgentStore.getState().setPreferredJobsNamespace(null);
464
- void hydrateFromBackend();
465
- if (isActiveRef.current) {
466
- useAgentStore.getState().setJobsUpgradeRequired({
467
- approvals: typed.approvals || [],
468
- toolCallIds: (typed.detail?.tool_call_ids as string[]) || [],
469
- message: String(typed.detail?.message || 'Pick a different organization for this job run.'),
470
- eligibleNamespaces: (typed.detail?.eligible_namespaces as string[]) || [],
471
- plan: ((typed.detail?.plan as 'free' | 'pro' | 'org') || 'free'),
472
- mode: 'namespace',
473
- });
474
- }
475
- return;
476
- }
477
  logger.error('useChat error:', error);
478
  if (isActiveRef.current) {
479
  useAgentStore.getState().setError(error.message);
@@ -828,9 +708,6 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
828
  if (a.edited_script) {
829
  useAgentStore.getState().setEditedScript(a.tool_call_id, a.edited_script);
830
  }
831
- if (a.namespace) {
832
- useAgentStore.getState().setApprovalNamespace(a.tool_call_id, a.namespace);
833
- }
834
  }
835
 
836
  // Update SDK tool state β€” this triggers sendMessages() via the transport
@@ -860,40 +737,6 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
860
  [sessionId, chat, updateSession, setNeedsAttention],
861
  );
862
 
863
- const declineBlockedJobs = useCallback(async () => {
864
- const blocked = useAgentStore.getState().jobsUpgradeRequired;
865
- if (!blocked) return false;
866
-
867
- const approvals = blocked.approvals.map((approval) => ({
868
- ...approval,
869
- approved: blocked.toolCallIds.includes(approval.tool_call_id) ? false : approval.approved,
870
- feedback: blocked.toolCallIds.includes(approval.tool_call_id)
871
- ? 'Rejected because this account cannot launch Hugging Face Jobs.'
872
- : approval.feedback,
873
- }));
874
-
875
- useAgentStore.getState().setJobsUpgradeRequired(null);
876
- return approveTools(approvals);
877
- }, [approveTools]);
878
-
879
- const continueBlockedJobsWithNamespace = useCallback(async (namespace: string) => {
880
- const blocked = useAgentStore.getState().jobsUpgradeRequired;
881
- if (!blocked) return false;
882
-
883
- const approvals = blocked.approvals.map((approval) => ({
884
- ...approval,
885
- namespace: blocked.toolCallIds.includes(approval.tool_call_id)
886
- ? namespace
887
- : approval.namespace,
888
- }));
889
-
890
- // Remember this choice so the picker doesn't reappear for every
891
- // subsequent hf_jobs call.
892
- useAgentStore.getState().setPreferredJobsNamespace(namespace);
893
- useAgentStore.getState().setJobsUpgradeRequired(null);
894
- return approveTools(approvals);
895
- }, [approveTools]);
896
-
897
  // -- Stop (interrupt backend agent loop, keep SSE open for events) --------
898
  const stop = useCallback(() => {
899
  // Don't call chat.stop() β€” keep the SSE stream open so the backend's
@@ -950,7 +793,5 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
950
  undoLastTurn,
951
  editAndRegenerate,
952
  approveTools,
953
- declineBlockedJobs,
954
- continueBlockedJobsWithNamespace,
955
  };
956
  }
 
330
  messages: UIMessage[];
331
  }>({ setMessages: null, messages: [] });
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  // -- useChat from Vercel AI SDK -----------------------------------------
334
  const chat = useChat({
335
  id: sessionId,
 
354
  }
355
  return;
356
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  logger.error('useChat error:', error);
358
  if (isActiveRef.current) {
359
  useAgentStore.getState().setError(error.message);
 
708
  if (a.edited_script) {
709
  useAgentStore.getState().setEditedScript(a.tool_call_id, a.edited_script);
710
  }
 
 
 
711
  }
712
 
713
  // Update SDK tool state β€” this triggers sendMessages() via the transport
 
737
  [sessionId, chat, updateSession, setNeedsAttention],
738
  );
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  // -- Stop (interrupt backend agent loop, keep SSE open for events) --------
741
  const stop = useCallback(() => {
742
  // Don't call chat.stop() β€” keep the SSE stream open so the backend's
 
793
  undoLastTurn,
794
  editAndRegenerate,
795
  approveTools,
 
 
796
  };
797
  }
frontend/src/hooks/useOrgMembership.ts DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * Polls backend for org membership status.
3
- * When membership is detected, updates the user in the agent store
4
- * and closes any org-join popup that was opened.
5
- */
6
- import { useEffect, useRef } from 'react';
7
- import { useAgentStore } from '@/store/agentStore';
8
-
9
- const POLL_INTERVAL_MS = 3000;
10
-
11
- /**
12
- * @param enabled Only poll when true (user is authenticated but not yet confirmed as org member)
13
- * @returns popupRef β€” assign `window.open()` result to `.current` so the hook can auto-close it
14
- */
15
- export function useOrgMembership(enabled: boolean) {
16
- const user = useAgentStore((s) => s.user);
17
- const setUser = useAgentStore((s) => s.setUser);
18
- const popupRef = useRef<Window | null>(null);
19
-
20
- useEffect(() => {
21
- if (!enabled || user?.orgMember) return;
22
-
23
- let cancelled = false;
24
-
25
- const check = async () => {
26
- try {
27
- const res = await fetch('/auth/org-membership', { credentials: 'include' });
28
- if (!res.ok || cancelled) return;
29
- const data = await res.json();
30
- if (cancelled) return;
31
- if (data.is_member && user) {
32
- setUser({ ...user, orgMember: true });
33
- try { popupRef.current?.close(); } catch { /* cross-origin or already closed */ }
34
- popupRef.current = null;
35
- }
36
- } catch { /* backend unreachable β€” skip */ }
37
- };
38
-
39
- check();
40
- const id = setInterval(check, POLL_INTERVAL_MS);
41
- return () => { cancelled = true; clearInterval(id); };
42
- }, [enabled, user?.orgMember, user, setUser]);
43
-
44
- return popupRef;
45
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -277,6 +277,15 @@ function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformS
277
  if (state === 'cancelled') {
278
  controller.enqueue({ type: 'tool-output-error', toolCallId: tcId, errorText: 'Cancelled by user', dynamic: true });
279
  }
 
 
 
 
 
 
 
 
 
280
  break;
281
  }
282
 
@@ -354,22 +363,13 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
354
  const approvals = approvedParts.map((p) => {
355
  if (p.type !== 'dynamic-tool') return null;
356
  const approved = p.approval?.approved ?? true;
357
- // Get edited script from agentStore if available
358
  const editedScript = useAgentStore.getState().getEditedScript(p.toolCallId);
359
- const explicitNamespace = useAgentStore.getState().getApprovalNamespace(p.toolCallId);
360
- // Fall back to the user's persisted choice so we don't re-prompt
361
- // every hf_jobs call. Backend will 400 if the saved namespace is
362
- // no longer valid; the error handler clears the preference and
363
- // reopens the picker.
364
- const preferred = useAgentStore.getState().preferredJobsNamespace;
365
- const namespace = explicitNamespace
366
- ?? (approved && p.toolName === 'hf_jobs' ? preferred ?? null : null);
367
  return {
368
  tool_call_id: p.toolCallId,
369
  approved,
370
  feedback: approved ? null : (p.approval?.reason || 'Rejected by user'),
371
  edited_script: editedScript ?? null,
372
- namespace: namespace ?? null,
373
  };
374
  }).filter(Boolean);
375
  body = { approvals };
@@ -407,44 +407,6 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
407
  // instead of a generic error banner.
408
  throw new Error('CLAUDE_QUOTA_EXHAUSTED');
409
  }
410
- if (response.status === 402) {
411
- const payload = await response.json().catch(() => null);
412
- if (payload?.detail?.error === 'hf_jobs_upgrade_required') {
413
- const err = new Error('HF_JOBS_UPGRADE_REQUIRED') as Error & {
414
- detail?: Record<string, unknown>;
415
- approvals?: Array<Record<string, unknown>>;
416
- };
417
- err.detail = payload.detail as Record<string, unknown>;
418
- err.approvals = (body.approvals as Array<Record<string, unknown>> | undefined) || [];
419
- throw err;
420
- }
421
- }
422
- if (response.status === 409) {
423
- const payload = await response.json().catch(() => null);
424
- if (payload?.detail?.error === 'hf_jobs_namespace_required') {
425
- const err = new Error('HF_JOBS_NAMESPACE_REQUIRED') as Error & {
426
- detail?: Record<string, unknown>;
427
- approvals?: Array<Record<string, unknown>>;
428
- };
429
- err.detail = payload.detail as Record<string, unknown>;
430
- err.approvals = (body.approvals as Array<Record<string, unknown>> | undefined) || [];
431
- throw err;
432
- }
433
- }
434
- if (response.status === 400) {
435
- const payload = await response.json().catch(() => null);
436
- if (payload?.detail?.error === 'hf_jobs_invalid_namespace') {
437
- // Stored namespace is no longer eligible β€” surface so the UI can
438
- // clear the saved preference and reopen the picker.
439
- const err = new Error('HF_JOBS_INVALID_NAMESPACE') as Error & {
440
- detail?: Record<string, unknown>;
441
- approvals?: Array<Record<string, unknown>>;
442
- };
443
- err.detail = payload.detail as Record<string, unknown>;
444
- err.approvals = (body.approvals as Array<Record<string, unknown>> | undefined) || [];
445
- throw err;
446
- }
447
- }
448
  if (!response.ok) {
449
  const errorText = await response.text().catch(() => 'Request failed');
450
  throw new Error(`Chat request failed: ${response.status} ${errorText}`);
 
277
  if (state === 'cancelled') {
278
  controller.enqueue({ type: 'tool-output-error', toolCallId: tcId, errorText: 'Cancelled by user', dynamic: true });
279
  }
280
+ if (state === 'billing_required') {
281
+ const namespace = (event.data?.namespace as string) || '';
282
+ useAgentStore.getState().setJobsUpgradeRequired({
283
+ namespace: namespace || null,
284
+ message: namespace
285
+ ? `Hugging Face Jobs need credits on the "${namespace}" namespace. Add some, then re-run the same job β€” the agent will pick it back up.`
286
+ : 'Hugging Face Jobs need credits on this namespace. Add some, then re-run the same job β€” the agent will pick it back up.',
287
+ });
288
+ }
289
  break;
290
  }
291
 
 
363
  const approvals = approvedParts.map((p) => {
364
  if (p.type !== 'dynamic-tool') return null;
365
  const approved = p.approval?.approved ?? true;
 
366
  const editedScript = useAgentStore.getState().getEditedScript(p.toolCallId);
 
 
 
 
 
 
 
 
367
  return {
368
  tool_call_id: p.toolCallId,
369
  approved,
370
  feedback: approved ? null : (p.approval?.reason || 'Rejected by user'),
371
  edited_script: editedScript ?? null,
372
+ namespace: null,
373
  };
374
  }).filter(Boolean);
375
  body = { approvals };
 
407
  // instead of a generic error banner.
408
  throw new Error('CLAUDE_QUOTA_EXHAUSTED');
409
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  if (!response.ok) {
411
  const errorText = await response.text().catch(() => 'Request failed');
412
  throw new Error(`Chat request failed: ${response.status} ${errorText}`);
frontend/src/store/agentStore.ts CHANGED
@@ -46,18 +46,8 @@ export interface LLMHealthError {
46
  }
47
 
48
  export interface JobsUpgradeState {
49
- approvals: Array<{
50
- tool_call_id: string;
51
- approved: boolean;
52
- feedback?: string | null;
53
- edited_script?: string | null;
54
- namespace?: string | null;
55
- }>;
56
- toolCallIds: string[];
57
  message: string;
58
- eligibleNamespaces: string[];
59
- plan: 'free' | 'pro' | 'org';
60
- mode: 'upgrade' | 'namespace';
61
  }
62
 
63
  export type ActivityStatus =
@@ -138,13 +128,6 @@ interface AgentStore {
138
  // Edited scripts (tool_call_id -> edited content)
139
  editedScripts: Record<string, string>;
140
 
141
- // Namespace overrides chosen for hf_jobs approvals (tool_call_id -> namespace)
142
- approvalNamespaces: Record<string, string>;
143
-
144
- // Persisted preferred namespace for hf_jobs (auto-applied to future approvals
145
- // so the user only picks once)
146
- preferredJobsNamespace: string | null;
147
-
148
  // Job URLs (tool_call_id -> job URL) for HF jobs
149
  jobUrls: Record<string, string>;
150
 
@@ -199,12 +182,6 @@ interface AgentStore {
199
  getEditedScript: (toolCallId: string) => string | undefined;
200
  clearEditedScripts: () => void;
201
 
202
- setApprovalNamespace: (toolCallId: string, namespace: string) => void;
203
- getApprovalNamespace: (toolCallId: string) => string | undefined;
204
- clearApprovalNamespaces: () => void;
205
-
206
- setPreferredJobsNamespace: (namespace: string | null) => void;
207
-
208
  setJobUrl: (toolCallId: string, jobUrl: string) => void;
209
  getJobUrl: (toolCallId: string) => string | undefined;
210
 
@@ -298,28 +275,6 @@ function saveTrackioDashboards(dashboards: Record<string, { spaceId: string; pro
298
  }
299
  }
300
 
301
- const PREFERRED_JOBS_NAMESPACE_KEY = 'hf-agent-preferred-jobs-namespace';
302
-
303
- function loadPreferredJobsNamespace(): string | null {
304
- try {
305
- return localStorage.getItem(PREFERRED_JOBS_NAMESPACE_KEY);
306
- } catch {
307
- return null;
308
- }
309
- }
310
-
311
- function savePreferredJobsNamespace(namespace: string | null): void {
312
- try {
313
- if (namespace) {
314
- localStorage.setItem(PREFERRED_JOBS_NAMESPACE_KEY, namespace);
315
- } else {
316
- localStorage.removeItem(PREFERRED_JOBS_NAMESPACE_KEY);
317
- }
318
- } catch (e) {
319
- console.warn('Failed to persist preferred jobs namespace:', e);
320
- }
321
- }
322
-
323
  export const useAgentStore = create<AgentStore>()((set, get) => ({
324
  sessionStates: {},
325
  activeSessionId: null,
@@ -340,8 +295,6 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
340
  plan: [],
341
 
342
  editedScripts: {},
343
- approvalNamespaces: {},
344
- preferredJobsNamespace: loadPreferredJobsNamespace(),
345
  jobUrls: {},
346
  jobStatuses: {},
347
  trackioDashboards: loadTrackioDashboards(),
@@ -513,21 +466,6 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
513
 
514
  clearEditedScripts: () => set({ editedScripts: {} }),
515
 
516
- setApprovalNamespace: (toolCallId, namespace) => {
517
- set((state) => ({
518
- approvalNamespaces: { ...state.approvalNamespaces, [toolCallId]: namespace },
519
- }));
520
- },
521
-
522
- getApprovalNamespace: (toolCallId) => get().approvalNamespaces[toolCallId],
523
-
524
- clearApprovalNamespaces: () => set({ approvalNamespaces: {} }),
525
-
526
- setPreferredJobsNamespace: (namespace) => {
527
- savePreferredJobsNamespace(namespace);
528
- set({ preferredJobsNamespace: namespace });
529
- },
530
-
531
  // ── Job URLs ────────────────────────────────────────────────────────
532
 
533
  setJobUrl: (toolCallId, jobUrl) => {
 
46
  }
47
 
48
  export interface JobsUpgradeState {
 
 
 
 
 
 
 
 
49
  message: string;
50
+ namespace?: string | null;
 
 
51
  }
52
 
53
  export type ActivityStatus =
 
128
  // Edited scripts (tool_call_id -> edited content)
129
  editedScripts: Record<string, string>;
130
 
 
 
 
 
 
 
 
131
  // Job URLs (tool_call_id -> job URL) for HF jobs
132
  jobUrls: Record<string, string>;
133
 
 
182
  getEditedScript: (toolCallId: string) => string | undefined;
183
  clearEditedScripts: () => void;
184
 
 
 
 
 
 
 
185
  setJobUrl: (toolCallId: string, jobUrl: string) => void;
186
  getJobUrl: (toolCallId: string) => string | undefined;
187
 
 
275
  }
276
  }
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  export const useAgentStore = create<AgentStore>()((set, get) => ({
279
  sessionStates: {},
280
  activeSessionId: null,
 
295
  plan: [],
296
 
297
  editedScripts: {},
 
 
298
  jobUrls: {},
299
  jobStatuses: {},
300
  trackioDashboards: loadTrackioDashboards(),
 
466
 
467
  clearEditedScripts: () => set({ editedScripts: {} }),
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  // ── Job URLs ────────────────────────────────────────────────────────
470
 
471
  setJobUrl: (toolCallId, jobUrl) => {
frontend/src/types/agent.ts CHANGED
@@ -35,5 +35,4 @@ export interface User {
35
  username?: string;
36
  name?: string;
37
  picture?: string;
38
- orgMember?: boolean;
39
  }
 
35
  username?: string;
36
  name?: string;
37
  picture?: string;
 
38
  }
tests/unit/test_hf_access.py CHANGED
@@ -1,55 +1,61 @@
1
- from agent.core.hf_access import jobs_access_from_whoami
2
 
3
 
4
- def test_personal_pro_prefers_username_namespace():
5
  access = jobs_access_from_whoami({
6
  "name": "alice",
7
- "plan": "pro",
8
  "orgs": [],
9
  })
10
- assert access.plan == "pro"
 
11
  assert access.eligible_namespaces == ["alice"]
12
  assert access.default_namespace == "alice"
13
 
14
 
15
- def test_free_user_with_paid_org_uses_org_namespace():
 
 
 
 
16
  access = jobs_access_from_whoami({
17
  "name": "alice",
18
- "plan": "free",
19
  "orgs": [
20
  {"name": "team-a", "plan": "team"},
21
  {"name": "oss-friends", "plan": "free"},
22
  ],
23
  })
24
- assert access.plan == "org"
25
- assert access.personal_can_run_jobs is False
26
- assert access.eligible_namespaces == ["team-a"]
27
- assert access.default_namespace is None
28
 
29
 
30
- def test_free_user_without_paid_org_cannot_run_jobs():
 
 
 
31
  access = jobs_access_from_whoami({
32
  "name": "alice",
33
- "plan": "free",
34
- "orgs": [{"name": "community", "plan": "free"}],
35
  })
36
- assert access.plan == "free"
37
- assert access.can_run_jobs is False
38
- assert access.eligible_namespaces == []
39
- assert access.default_namespace is None
40
 
41
 
42
- def test_oauth_pro_user_recognized_via_is_pro_flag():
43
- # OAuth login surfaces Pro status only as `isPro: true`; the `type` key is
44
- # a generic "user" string. Regression test for Space discussion #21 β€” Pro
45
- # OAuth users were being classified as free and blocked from Jobs.
46
  access = jobs_access_from_whoami({
47
- "name": "alice",
48
- "type": "user",
49
- "isPro": True,
50
- "orgs": [],
51
  })
52
- assert access.plan == "pro"
53
- assert access.personal_can_run_jobs is True
54
- assert access.eligible_namespaces == ["alice"]
55
- assert access.default_namespace == "alice"
 
 
 
 
 
 
 
 
 
1
+ from agent.core.hf_access import is_billing_error, jobs_access_from_whoami
2
 
3
 
4
+ def test_personal_user_lists_username_namespace():
5
  access = jobs_access_from_whoami({
6
  "name": "alice",
 
7
  "orgs": [],
8
  })
9
+ assert access.username == "alice"
10
+ assert access.org_names == []
11
  assert access.eligible_namespaces == ["alice"]
12
  assert access.default_namespace == "alice"
13
 
14
 
15
+ def test_user_with_orgs_lists_all_namespaces_regardless_of_plan():
16
+ # Plan/tier is ignored β€” credits live on the namespace itself, so any
17
+ # org the user belongs to is eligible. We sort orgs alphabetically and
18
+ # always put the personal namespace first so the picker default is the
19
+ # user's own account.
20
  access = jobs_access_from_whoami({
21
  "name": "alice",
 
22
  "orgs": [
23
  {"name": "team-a", "plan": "team"},
24
  {"name": "oss-friends", "plan": "free"},
25
  ],
26
  })
27
+ assert access.username == "alice"
28
+ assert access.org_names == ["oss-friends", "team-a"]
29
+ assert access.eligible_namespaces == ["alice", "oss-friends", "team-a"]
30
+ assert access.default_namespace == "alice"
31
 
32
 
33
+ def test_free_user_without_org_still_eligible_under_personal_namespace():
34
+ # Pro is no longer required β€” the user is offered their personal
35
+ # namespace; whether they actually have credits is decided at job
36
+ # creation time when HF returns a 402 / billing error.
37
  access = jobs_access_from_whoami({
38
  "name": "alice",
39
+ "orgs": [],
 
40
  })
41
+ assert access.eligible_namespaces == ["alice"]
42
+ assert access.default_namespace == "alice"
 
 
43
 
44
 
45
+ def test_org_only_token_falls_back_to_first_org():
 
 
 
46
  access = jobs_access_from_whoami({
47
+ "name": None,
48
+ "orgs": [{"name": "team-a"}, {"name": "team-b"}],
 
 
49
  })
50
+ assert access.username is None
51
+ assert access.eligible_namespaces == ["team-a", "team-b"]
52
+ assert access.default_namespace == "team-a"
53
+
54
+
55
+ def test_is_billing_error_detects_402_and_credit_phrasing():
56
+ assert is_billing_error("402 Payment Required")
57
+ assert is_billing_error("Insufficient credits on namespace foo")
58
+ assert is_billing_error("This namespace requires credits to run jobs")
59
+ assert is_billing_error("Out of credit, please add billing")
60
+ assert not is_billing_error("Internal server error")
61
+ assert not is_billing_error("")