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 +52 -65
- agent/tools/jobs_tool.py +43 -14
- backend/routes/agent.py +7 -107
- backend/routes/auth.py +4 -20
- frontend/src/components/Chat/ChatInput.tsx +28 -16
- frontend/src/components/JobsUpgradeDialog.tsx +77 -108
- frontend/src/components/SessionChat.tsx +1 -3
- frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +15 -94
- frontend/src/hooks/useAgentChat.ts +0 -159
- frontend/src/hooks/useOrgMembership.ts +0 -45
- frontend/src/lib/sse-chat-transport.ts +10 -48
- frontend/src/store/agentStore.ts +1 -63
- frontend/src/types/agent.ts +0 -1
- tests/unit/test_hf_access.py +35 -29
|
@@ -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 |
-
"""
|
| 18 |
|
| 19 |
username: str | None
|
| 20 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 58 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 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
|
| 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 |
-
"
|
| 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.
|
| 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))
|
|
@@ -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 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 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
|
| 1133 |
-
"or
|
| 1134 |
-
"
|
| 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": {
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -10,7 +10,7 @@ import time
|
|
| 10 |
from urllib.parse import urlencode
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
-
from dependencies import AUTH_ENABLED,
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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,
|
| 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: '
|
| 227 |
});
|
| 228 |
} catch {
|
| 229 |
/* tracking is best-effort */
|
| 230 |
}
|
| 231 |
}, [sessionId, jobsUpgradeRequired]);
|
| 232 |
|
| 233 |
-
const
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 488 |
onClose={handleJobsUpgradeClose}
|
| 489 |
onUpgrade={handleJobsUpgradeClick}
|
| 490 |
-
|
| 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>
|
|
@@ -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
|
|
|
|
| 17 |
|
| 18 |
interface JobsUpgradeDialogProps {
|
| 19 |
open: boolean;
|
| 20 |
-
mode: 'upgrade' | 'namespace';
|
| 21 |
message: string;
|
| 22 |
-
|
|
|
|
| 23 |
onUpgrade: () => void;
|
| 24 |
-
|
| 25 |
onClose: () => void;
|
| 26 |
-
onContinueWithNamespace: (namespace: string) => void;
|
| 27 |
}
|
| 28 |
|
| 29 |
export default function JobsUpgradeDialog({
|
| 30 |
open,
|
| 31 |
-
mode,
|
| 32 |
message,
|
| 33 |
-
|
| 34 |
onUpgrade,
|
| 35 |
-
|
| 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: {
|
|
|
|
|
|
|
| 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={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
>
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</DialogTitle>
|
| 76 |
<DialogContent sx={{ px: 3, pt: 1.25, pb: 0 }}>
|
| 77 |
-
<
|
| 78 |
-
sx={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
>
|
| 80 |
-
{
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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 |
-
{
|
| 143 |
<Button
|
| 144 |
-
onClick={
|
| 145 |
-
|
| 146 |
variant="contained"
|
| 147 |
size="small"
|
| 148 |
sx={{
|
| 149 |
fontSize: '0.82rem',
|
| 150 |
px: 2.5,
|
| 151 |
-
bgcolor:
|
| 152 |
color: '#000',
|
| 153 |
textTransform: 'none',
|
| 154 |
fontWeight: 700,
|
| 155 |
-
boxShadow: '
|
| 156 |
-
'&:hover': { bgcolor: '#FFB340', boxShadow: '
|
| 157 |
}}
|
| 158 |
>
|
| 159 |
-
|
| 160 |
</Button>
|
| 161 |
) : (
|
| 162 |
<Button
|
| 163 |
component="a"
|
| 164 |
-
href={
|
| 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:
|
| 174 |
color: '#000',
|
| 175 |
textTransform: 'none',
|
| 176 |
fontWeight: 700,
|
| 177 |
-
boxShadow: '
|
| 178 |
-
'&:hover': { bgcolor: '#FFB340', boxShadow: '
|
| 179 |
}}
|
| 180 |
>
|
| 181 |
-
|
| 182 |
</Button>
|
| 183 |
)}
|
| 184 |
<Button
|
| 185 |
-
onClick={
|
| 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 |
-
|
| 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>
|
|
@@ -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
|
| 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={
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useCallback,
|
| 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
|
| 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:
|
| 376 |
-
<
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 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:
|
| 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="
|
| 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
|
|
@@ -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 |
}
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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:
|
| 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}`);
|
|
@@ -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 |
-
|
| 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) => {
|
|
@@ -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 |
}
|
|
@@ -1,55 +1,61 @@
|
|
| 1 |
-
from agent.core.hf_access import jobs_access_from_whoami
|
| 2 |
|
| 3 |
|
| 4 |
-
def
|
| 5 |
access = jobs_access_from_whoami({
|
| 6 |
"name": "alice",
|
| 7 |
-
"plan": "pro",
|
| 8 |
"orgs": [],
|
| 9 |
})
|
| 10 |
-
assert access.
|
|
|
|
| 11 |
assert access.eligible_namespaces == ["alice"]
|
| 12 |
assert access.default_namespace == "alice"
|
| 13 |
|
| 14 |
|
| 15 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 25 |
-
assert access.
|
| 26 |
-
assert access.eligible_namespaces == ["team-a"]
|
| 27 |
-
assert access.default_namespace
|
| 28 |
|
| 29 |
|
| 30 |
-
def
|
|
|
|
|
|
|
|
|
|
| 31 |
access = jobs_access_from_whoami({
|
| 32 |
"name": "alice",
|
| 33 |
-
"
|
| 34 |
-
"orgs": [{"name": "community", "plan": "free"}],
|
| 35 |
})
|
| 36 |
-
assert access.
|
| 37 |
-
assert access.
|
| 38 |
-
assert access.eligible_namespaces == []
|
| 39 |
-
assert access.default_namespace is None
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 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":
|
| 48 |
-
"
|
| 49 |
-
"isPro": True,
|
| 50 |
-
"orgs": [],
|
| 51 |
})
|
| 52 |
-
assert access.
|
| 53 |
-
assert access.
|
| 54 |
-
assert access.
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("")
|