evalstate HF Staff commited on
Commit
fb5e1ff
·
verified ·
1 Parent(s): 0bc98aa

Deploy hub-query staging with fast-agent OAuth

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ wheels/fast_agent_mcp-0.7.18-py3-none-any.whl filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ RUN apt-get update && \
4
+ apt-get install -y \
5
+ bash \
6
+ git git-lfs \
7
+ wget curl procps \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
11
+
12
+ WORKDIR /app
13
+
14
+ COPY wheels /tmp/wheels
15
+ RUN uv pip install --system --no-cache \
16
+ /tmp/wheels/fast_agent_mcp-0.7.18-py3-none-any.whl \
17
+ huggingface_hub \
18
+ "pydantic-monty==0.0.17"
19
+
20
+ COPY --link ./ /app
21
+ RUN chown -R 1000:1000 /app
22
+ USER 1000
23
+
24
+ ENV FAST_AGENT_SERVE_OAUTH=huggingface
25
+ ENV FAST_AGENT_OAUTH_SCOPES=inference-api
26
+
27
+ EXPOSE 7860
28
+
29
+ CMD ["fast-agent", "serve", \
30
+ "--card", "hf-hub-query.md", \
31
+ "--transport", "http", \
32
+ "--host", "0.0.0.0", \
33
+ "--port", "7860", \
34
+ "--instance-scope", "request"]
README.md CHANGED
@@ -1,10 +1,50 @@
1
  ---
2
- title: Hub Query Staging
3
- emoji: 📉
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: hub-query-staging
3
+ emoji: 🔎
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
+ short_description: Staging fast-agent MCP server for HF Hub queries.
9
+ hf_oauth: true
10
+ hf_oauth_expiration_minutes: 480
11
+ hf_oauth_scopes:
12
+ - inference-api
13
  ---
14
 
15
+ # hub-query-staging
16
+
17
+ This staging Space runs a raw-passthrough fast-agent MCP server backed by the custom Monty build used for Hugging Face Hub querying.
18
+
19
+ The deployed card uses `tool_result_mode: passthrough`, so tool results are returned directly rather than rewritten by a second LLM pass.
20
+
21
+ ## Auth
22
+
23
+ This Space is configured for Hugging Face OAuth/token passthrough:
24
+
25
+ - `FAST_AGENT_SERVE_OAUTH=huggingface`
26
+ - `FAST_AGENT_OAUTH_SCOPES=inference-api`
27
+ - `--instance-scope request`
28
+
29
+ The README enables Space OAuth (`hf_oauth: true`) and the container sets the fast-agent OAuth environment. `SPACE_HOST` is used by fast-agent to derive the public OAuth resource URL. No shared `HF_TOKEN` is required for user-scoped OAuth/token pass-through.
30
+
31
+ Clients can either:
32
+ - send `Authorization: Bearer <HF_TOKEN>` directly, or
33
+ - use MCP OAuth discovery/auth flow
34
+
35
+ ## Model
36
+
37
+ The deployed card uses:
38
+
39
+ - `hf.openai/gpt-oss-120b:sambanova`
40
+
41
+ ## Main files
42
+
43
+ - `hf-hub-query.md` — raw MCP card
44
+ - `tool_entrypoints.py` / `monty_api/` — Hub query tool implementation
45
+ - `_monty_codegen_shared.md` — shared codegen instructions
46
+ - `wheels/fast_agent_mcp-0.7.18-py3-none-any.whl` — staging fast-agent wheel from `../fast-agent-pr/`
47
+
48
+ ## Dependencies
49
+
50
+ The Space installs the staged `fast-agent-mcp` wheel from this repo and `pydantic-monty==0.0.17` from PyPI.
_monty_codegen_shared.md ADDED
@@ -0,0 +1,784 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Code Generation Rules
2
+
3
+ - You are writing Python to be executed in a secure runtime environment.
4
+ - **NEVER** use `import` - it is NOT available in this environment.
5
+ - All helper calls are async: always use `await`.
6
+ - Write a top-level Monty Python script. Use a shape like:
7
+
8
+ ```py
9
+ resp = await hf_models_search(limit=min(max_calls, 10))
10
+ result = resp["items"]
11
+ result
12
+ ```
13
+
14
+ - `max_calls` is a runtime-provided top-level input.
15
+ - `max_calls` is the total external-call budget for the whole program.
16
+ - Always assign the final output to `result`.
17
+ - End the script with a final line containing only `result`.
18
+ - Never stop after `result = ...`; always add a final bare `result` line.
19
+ - Do **not** define or call `solve(...)`.
20
+ - Use only documented `hf_*` helpers.
21
+ - `result` must be plain Python data only: `dict`, `list`, `str`, `int`, `float`, `bool`, or `None`.
22
+ - Do **not** hand-build JSON strings, markdown strings, or your own transport wrapper like `{result: ..., meta: ...}` unless the user explicitly asked for prose.
23
+ - If the user says "return only" some fields, make `result` exactly that shape.
24
+ - If a helper already returns the requested row shape, use `resp["items"]` directly **only when helper coverage is clearly complete**. If helper `meta` suggests partial/unknown coverage, set `result = {"results": resp["items"], "coverage": resp["meta"]}` instead of bare items.
25
+ - For current-user prompts (`my`, `me`), try helpers with `username=None` / `handle=None` first.
26
+ - For current-user follower/following aggregation prompts, prefer `hf_user_graph(relation=..., ...)` directly instead of `hf_whoami()` plus a second graph call. This saves a call and avoids unnecessary branching.
27
+ - If a current-user helper returns `ok=false`, assign that helper response to `result`.
28
+ - For relationship / aggregation questions (followers, members, likes, likers, intersections), preserve attribution in `result` unless the user explicitly asked for a collapsed deduped list.
29
+ - Do **not** choose tiny hard-coded limits like `5` for follower/member/likes aggregation unless the user explicitly asked for a tiny sample. Prefer larger limits and preserve coverage when partial.
30
+ - If you branch on an error path, you must still end the module with a final top-level bare `result` line outside every `if` / loop.
31
+
32
+ ## Search rules
33
+
34
+ - If the user is asking about models, use `hf_models_search(...)`.
35
+ - If the user is asking about datasets, use `hf_datasets_search(...)`.
36
+ - If the user is asking about spaces, use `hf_spaces_search(...)`.
37
+ - Use `hf_repo_search(...)` only for intentionally cross-type search.
38
+ - Use `hf_trending(...)` only for the small "what is trending right now" feed.
39
+ - If the user says "trending" but also adds searchable constraints like `pipeline_tag`, `author`, search text, or `num_params` bounds, prefer the repo search helper sorted by `trending_score`.
40
+ - Think of search helpers as filter-first discovery and `hf_trending(...)` as rank-first current-feed inspection.
41
+
42
+ ## Parameter notes
43
+
44
+ - Trust the generated helper contracts below for per-helper params, fields, sort keys, expand values, and defaults.
45
+ - When the user asks for helper-owned coverage metadata, use `helper_resp["meta"]`.
46
+ - Treat any of the following helper-meta signals as coverage-sensitive: `limit_boundary_hit`, `truncated`, `more_available` not equal to `False`, `sample_complete=false`, `exact_count=false`, `ranking_complete=false`, `ranking_window_hit=true`, or `hard_cap_applied=true`. In those cases, do **not** return bare items; return `{"results": ..., "coverage": ...}`.
47
+ - For pro-only follower/member/liker queries, prefer `pro_only=True` instead of filtering on a projected field.
48
+ - `hf_user_likes(...)` already returns full normalized like rows by default; omit `fields` unless the user asked for a subset.
49
+ - When sorting `hf_user_likes(...)` by `repo_likes` or `repo_downloads`, set `ranking_window=50` unless the user explicitly asked for a narrower recent window.
50
+ - For human-facing follower/member/liker lists without an explicit requested count, prefer `limit=100` and return coverage when more may exist.
51
+ - For follower/following/member/liker queries that require local filtering on actor fields such as `username` or `fullname`, prefer a bounded scan like `limit=100` / `scan_limit=100` by default, or at most about `200` when a slightly broader sample is justified. Do **not** jump to `1000` unless the user explicitly asked for exhaustive coverage or a very large sample.
52
+ - Unknown `fields` / `where` keys now fail fast. Use only canonical field names.
53
+ - Ownership phrasing like "what collections does Qwen have", "collections by Qwen", or "collections owned by Qwen" means an owner lookup, so use `hf_collections_search(owner="Qwen")`, not a keyword-only `query="Qwen"` search; it filters owners case-insensitively.
54
+ - Ownership phrasing like "what spaces does X have", "what models does X have", or "what datasets does X have" means an author/owner inventory lookup, so use `hf_spaces_search(author="X")`, `hf_models_search(author="X")`, or `hf_datasets_search(author="X")` rather than a global keyword-only search.
55
+ - For profile/detail/social questions about a user or org — bio, description, display name, website, GitHub, Twitter/X, LinkedIn, Bluesky, organizations, or pro status — use `hf_profile_summary(...)` first.
56
+ - For join-style questions that need profile details for followers, following, members, likers, or other actor lists, first fetch a **bounded** actor list, filter locally on actor fields like `username` / `fullname`, then hydrate only the bounded matches with `hf_profile_summary(...)`.
57
+ - Do **not** set the initial actor-list limit equal to the whole remaining call budget when each match needs a follow-up profile lookup; reserve budget for the profile-detail calls and return coverage if the hydration step is partial.
58
+ - For exact aggregate counts like "how many models/datasets/spaces does X have", prefer `hf_profile_summary(...)['item']` counts. Those overview-owned counts may differ slightly from visible public search/list results, so if the user also asked for the list, preserve that distinction.
59
+ - For owner inventory queries without an explicit requested count, use `hf_profile_summary(...)` first when a specific owner is known. If the count is modest, use it to size the follow-up list call; otherwise return a bounded list plus coverage instead of pretending completeness.
60
+ - Think like `huggingface_hub`: `search`, `filter`, `author`, repo-type-specific upstream params, then `fields`.
61
+ - Push constraints upstream whenever a first-class helper argument exists.
62
+ - `post_filter` is only for normalized row filters that cannot be pushed upstream.
63
+ - `num_params` is a first-class upstream model-search arg; use `num_params="min:6B,max:128B"` instead of `post_filter` when possible.
64
+ - For created/updated date constraints, pair local `post_filter` with the matching sort (`created_at` or `last_modified`). Do **not** rely on date-only `post_filter` over an unsorted repo search window.
65
+ - Keep `post_filter` simple:
66
+ - exact match or `in` for returned fields like `runtime_stage`
67
+ - `gte` / `lte` for normalized numeric fields like `downloads` and `likes`
68
+ - `gte` / `lte` also work for normalized ISO timestamp fields like `created_at` and `last_modified`
69
+ - Do **not** use `post_filter` for things that already have first-class upstream params like `author`, `pipeline_tag`, `num_params` on model search, `dataset_name`, `language`, `models`, or `datasets`.
70
+
71
+ Examples:
72
+
73
+ ```py
74
+ result = await hf_models_search(pipeline_tag="text-to-image", limit=10)
75
+ result
76
+ ```
77
+
78
+ ```py
79
+ result = await hf_models_search(
80
+ pipeline_tag="text-generation",
81
+ num_params="min:20B,max:80B",
82
+ sort="trending_score",
83
+ limit=50,
84
+ )
85
+ result
86
+ ```
87
+
88
+ ```py
89
+ result = await hf_collections_search(owner="Qwen", limit=10)
90
+ result
91
+ ```
92
+
93
+ Field-only pattern:
94
+
95
+ ```py
96
+ resp = await hf_models_search(
97
+ pipeline_tag="text-to-image",
98
+ fields=["repo_id", "author", "likes", "downloads", "repo_url"],
99
+ limit=3,
100
+ )
101
+ result = resp["items"]
102
+ result
103
+ ```
104
+
105
+ Coverage pattern:
106
+
107
+ ```py
108
+ resp = await hf_user_likes(
109
+ username="julien-c",
110
+ sort="repo_likes",
111
+ ranking_window=50,
112
+ limit=20,
113
+ fields=["repo_id", "repo_likes", "repo_url"],
114
+ )
115
+ result = {"results": resp["items"], "coverage": resp["meta"]}
116
+ result
117
+ ```
118
+
119
+ Owner-inventory pattern:
120
+
121
+ ```py
122
+ profile = await hf_profile_summary(handle="huggingface")
123
+ count = (profile.get("item") or {}).get("spaces_count")
124
+ limit = 200 if not isinstance(count, int) else min(max(count, 1), 200)
125
+ resp = await hf_spaces_search(
126
+ author="huggingface",
127
+ limit=limit,
128
+ fields=["repo_id", "repo_url"],
129
+ )
130
+ meta = resp.get("meta") or {}
131
+ if meta.get("limit_boundary_hit") or meta.get("more_available") not in {False, None}:
132
+ result = {"results": resp["items"], "coverage": {**meta, "profile_spaces_count": count}}
133
+ else:
134
+ result = resp["items"]
135
+ result
136
+ ```
137
+
138
+ Follower-profile join pattern:
139
+
140
+ ```py
141
+ followers_resp = await hf_user_graph(
142
+ relation="followers",
143
+ limit=100,
144
+ scan_limit=100,
145
+ fields=["username", "fullname"],
146
+ )
147
+ followers = followers_resp.get("items") or []
148
+ matches = []
149
+ for follower in followers:
150
+ username = follower.get("username")
151
+ fullname = follower.get("fullname")
152
+ starts_with_b = (
153
+ (isinstance(username, str) and username.lower().startswith("b"))
154
+ or (isinstance(fullname, str) and fullname.lower().startswith("b"))
155
+ )
156
+ if starts_with_b:
157
+ matches.append(follower)
158
+ remaining_profile_calls = max(0, max_calls - 1)
159
+ results = []
160
+ for follower in matches[:remaining_profile_calls]:
161
+ username = follower.get("username")
162
+ if not username:
163
+ continue
164
+ profile = await hf_profile_summary(handle=username)
165
+ item = profile.get("item") or {}
166
+ results.append(
167
+ {
168
+ "username": username,
169
+ "fullname": follower.get("fullname"),
170
+ "github_url": item.get("github_url"),
171
+ }
172
+ )
173
+ result = {
174
+ "results": results,
175
+ "coverage": {
176
+ "followers": followers_resp.get("meta") or {},
177
+ "matching_followers_seen": len(matches),
178
+ "profile_calls_used": len(results),
179
+ "profile_hydration_partial": len(matches) > len(results),
180
+ },
181
+ }
182
+ result
183
+ ```
184
+
185
+ Follower-likes aggregation pattern:
186
+
187
+ ```py
188
+ followers_resp = await hf_user_graph(relation="followers", limit=100, fields=["username"])
189
+ followers = followers_resp.get("items") or []
190
+ results = []
191
+ for follower in followers:
192
+ username = follower.get("username")
193
+ if not username:
194
+ continue
195
+ likes_resp = await hf_user_likes(
196
+ username=username,
197
+ repo_types=["model"],
198
+ limit=20,
199
+ fields=["repo_id", "liked_at"],
200
+ )
201
+ results.append(
202
+ {
203
+ "follower": username,
204
+ "liked_models": likes_resp.get("items") or [],
205
+ }
206
+ )
207
+ coverage = {
208
+ "followers": followers_resp.get("meta") or {},
209
+ }
210
+ result = {"results": results, "coverage": coverage}
211
+ result
212
+ ```
213
+
214
+ Current-user pro-follower model-likes pattern:
215
+
216
+ ```py
217
+ followers_resp = await hf_user_graph(
218
+ relation="followers",
219
+ pro_only=True,
220
+ limit=100,
221
+ fields=["username"],
222
+ )
223
+ followers = followers_resp.get("items") or []
224
+ remaining_calls = max(0, max_calls - 1)
225
+ results = {}
226
+ partial = (
227
+ (followers_resp.get("meta") or {}).get("limit_boundary_hit")
228
+ or (followers_resp.get("meta") or {}).get("more_available") not in {False, None}
229
+ )
230
+ processed_followers = 0
231
+ for follower in followers:
232
+ if remaining_calls <= 0:
233
+ partial = True
234
+ break
235
+ username = follower.get("username")
236
+ if not username:
237
+ continue
238
+ likes_resp = await hf_user_likes(
239
+ username=username,
240
+ repo_types=["model"],
241
+ limit=2,
242
+ fields=["repo_id", "repo_author", "liked_at"],
243
+ )
244
+ remaining_calls -= 1
245
+ likes_meta = likes_resp.get("meta") or {}
246
+ if likes_meta.get("limit_boundary_hit") or likes_meta.get("more_available") not in {False, None}:
247
+ partial = True
248
+ items = likes_resp.get("items") or []
249
+ if items:
250
+ results[username] = items
251
+ processed_followers += 1
252
+ coverage = {
253
+ "followers": followers_resp.get("meta") or {},
254
+ "processed_followers": processed_followers,
255
+ "partial": partial,
256
+ }
257
+ result = {"results": results, "coverage": coverage}
258
+ result
259
+ ```
260
+
261
+ ## Navigation graph
262
+
263
+ Use the helper that matches the question type.
264
+
265
+ - exact repo details → `hf_repo_details(...)`
266
+ - model search/list/discovery → `hf_models_search(...)`
267
+ - dataset search/list/discovery → `hf_datasets_search(...)`
268
+ - space search/list/discovery → `hf_spaces_search(...)`
269
+ - cross-type repo search → `hf_repo_search(...)`
270
+ - trending repos → `hf_trending(...)`
271
+ - daily papers → `hf_daily_papers(...)`
272
+ - repo discussions → `hf_repo_discussions(...)`
273
+ - specific discussion details → `hf_repo_discussion_details(...)`
274
+ - users who liked one repo → `hf_repo_likers(...)`
275
+ - profile / overview / social/detail / aggregate counts → `hf_profile_summary(...)`
276
+ - followers / following lists → `hf_user_graph(...)`
277
+ - repos a user liked → `hf_user_likes(...)`
278
+ - recent activity feed → `hf_recent_activity(...)`
279
+ - organization members → `hf_org_members(...)`
280
+ - collections search → `hf_collections_search(...)`
281
+ - items inside a known collection → `hf_collection_items(...)`
282
+ - explicit current username → `hf_whoami()`
283
+
284
+ Direction reminders:
285
+ - `hf_user_likes(...)` = user → repos
286
+ - `hf_repo_likers(...)` = repo → users
287
+ - `hf_user_graph(...)` = user/org → followers/following
288
+
289
+ ## Helper result shape
290
+
291
+ All helpers return:
292
+
293
+ ```py
294
+ {
295
+ "ok": bool,
296
+ "item": dict | None,
297
+ "items": list[dict],
298
+ "meta": dict,
299
+ "error": str | None,
300
+ }
301
+ ```
302
+
303
+ Rules:
304
+ - `items` is the canonical list field.
305
+ - `item` is just a singleton convenience.
306
+ - `meta` contains helper-owned execution, limit, and coverage info.
307
+
308
+ ## High-signal output rules
309
+
310
+ - Prefer compact dict/list outputs over prose when the user asked for fields.
311
+ - Use canonical snake_case keys in generated code and structured output.
312
+ - Use `repo_id` as the display label for repos.
313
+ - For joins/intersections/rankings, fetch the needed working set first and compute locally.
314
+ - If the result is partial, use top-level keys `results` and `coverage`.
315
+
316
+ ## Helper signatures (generated from Python)
317
+
318
+ These signatures are exported from the live runtime with `inspect.signature(...)`.
319
+ If prompt prose and signatures disagree, trust these signatures.
320
+
321
+ ```py
322
+ await hf_collection_items(collection_id: 'str', repo_types: 'list[str] | None' = None, limit: 'int' = 100, count_only: 'bool' = False, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
323
+
324
+ await hf_collections_search(query: 'str | None' = None, owner: 'str | None' = None, limit: 'int' = 20, count_only: 'bool' = False, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
325
+
326
+ await hf_daily_papers(limit: 'int' = 20, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
327
+
328
+ await hf_datasets_search(search: 'str | None' = None, filter: 'str | list[str] | None' = None, author: 'str | None' = None, benchmark: 'str | bool | None' = None, dataset_name: 'str | None' = None, gated: 'bool | None' = None, language_creators: 'str | list[str] | None' = None, language: 'str | list[str] | None' = None, multilinguality: 'str | list[str] | None' = None, size_categories: 'str | list[str] | None' = None, task_categories: 'str | list[str] | None' = None, task_ids: 'str | list[str] | None' = None, sort: 'str | None' = None, limit: 'int' = 100, expand: 'list[str] | None' = None, full: 'bool | None' = None, fields: 'list[str] | None' = None, post_filter: 'dict[str, Any] | None' = None) -> 'dict[str, Any]'
329
+
330
+ await hf_models_search(search: 'str | None' = None, filter: 'str | list[str] | None' = None, author: 'str | None' = None, apps: 'str | list[str] | None' = None, gated: 'bool | None' = None, inference: 'str | None' = None, inference_provider: 'str | list[str] | None' = None, model_name: 'str | None' = None, trained_dataset: 'str | list[str] | None' = None, pipeline_tag: 'str | None' = None, num_params: 'str | None' = None, emissions_thresholds: 'tuple[float, float] | None' = None, sort: 'str | None' = None, limit: 'int' = 100, expand: 'list[str] | None' = None, full: 'bool | None' = None, card_data: 'bool' = False, fetch_config: 'bool' = False, fields: 'list[str] | None' = None, post_filter: 'dict[str, Any] | None' = None) -> 'dict[str, Any]'
331
+
332
+ await hf_org_members(organization: 'str', limit: 'int | None' = None, scan_limit: 'int | None' = None, count_only: 'bool' = False, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
333
+
334
+ await hf_profile_summary(handle: 'str | None' = None, include: 'list[str] | None' = None, likes_limit: 'int' = 10, activity_limit: 'int' = 10) -> 'dict[str, Any]'
335
+
336
+ await hf_recent_activity(feed_type: 'str | None' = None, entity: 'str | None' = None, activity_types: 'list[str] | None' = None, repo_types: 'list[str] | None' = None, limit: 'int | None' = None, max_pages: 'int | None' = None, start_cursor: 'str | None' = None, count_only: 'bool' = False, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
337
+
338
+ await hf_repo_details(repo_id: 'str | None' = None, repo_ids: 'list[str] | None' = None, repo_type: 'str' = 'auto', fields: 'list[str] | None' = None) -> 'dict[str, Any]'
339
+
340
+ await hf_repo_discussion_details(repo_type: 'str', repo_id: 'str', discussion_num: 'int', fields: 'list[str] | None' = None) -> 'dict[str, Any]'
341
+
342
+ await hf_repo_discussions(repo_type: 'str', repo_id: 'str', limit: 'int' = 20, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
343
+
344
+ await hf_repo_likers(repo_id: 'str', repo_type: 'str', limit: 'int | None' = None, count_only: 'bool' = False, pro_only: 'bool | None' = None, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
345
+
346
+ await hf_repo_search(search: 'str | None' = None, repo_type: 'str | None' = None, repo_types: 'list[str] | None' = None, filter: 'str | list[str] | None' = None, author: 'str | None' = None, sort: 'str | None' = None, limit: 'int' = 100, fields: 'list[str] | None' = None, post_filter: 'dict[str, Any] | None' = None) -> 'dict[str, Any]'
347
+
348
+ await hf_runtime_capabilities(section: 'str | None' = None) -> 'dict[str, Any]'
349
+
350
+ await hf_spaces_search(search: 'str | None' = None, filter: 'str | list[str] | None' = None, author: 'str | None' = None, datasets: 'str | list[str] | None' = None, models: 'str | list[str] | None' = None, linked: 'bool' = False, sort: 'str | None' = None, limit: 'int' = 100, expand: 'list[str] | None' = None, full: 'bool | None' = None, fields: 'list[str] | None' = None, post_filter: 'dict[str, Any] | None' = None) -> 'dict[str, Any]'
351
+
352
+ await hf_trending(repo_type: 'str' = 'model', limit: 'int' = 20, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
353
+
354
+ await hf_user_graph(username: 'str | None' = None, relation: 'str' = 'followers', limit: 'int | None' = None, scan_limit: 'int | None' = None, count_only: 'bool' = False, pro_only: 'bool | None' = None, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None) -> 'dict[str, Any]'
355
+
356
+ await hf_user_likes(username: 'str | None' = None, repo_types: 'list[str] | None' = None, limit: 'int | None' = None, scan_limit: 'int | None' = None, count_only: 'bool' = False, where: 'dict[str, Any] | None' = None, fields: 'list[str] | None' = None, sort: 'str | None' = None, ranking_window: 'int | None' = None) -> 'dict[str, Any]'
357
+
358
+ await hf_whoami() -> 'dict[str, Any]'
359
+ ```
360
+
361
+ ## Helper contracts (generated from runtime + wrapper metadata)
362
+
363
+ These contracts describe the normalized wrapper surface exposed to generated code.
364
+ Field names and helper-visible enum values are canonical snake_case wrapper names.
365
+
366
+ All helpers return the same envelope: `{ok, item, items, meta, error}`.
367
+
368
+ ### hf_collection_items
369
+
370
+ - category: `collection_navigation`
371
+ - returns:
372
+ - envelope: `{ok, item, items, meta, error}`
373
+ - row_type: `repo`
374
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
375
+ - guaranteed_fields: `repo_id`, `repo_type`, `repo_url`
376
+ - optional_fields: `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
377
+ - supported_params: `collection_id`, `repo_types`, `limit`, `count_only`, `where`, `fields`
378
+ - param_values:
379
+ - repo_types: `model`, `dataset`, `space`
380
+ - fields_contract:
381
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
382
+ - canonical_only: `true`
383
+ - where_contract:
384
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
385
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
386
+ - normalized_only: `true`
387
+ - limit_contract:
388
+ - default_limit: `100`
389
+ - max_limit: `500`
390
+ - notes: Returns repos inside one collection as summary rows.
391
+
392
+ ### hf_collections_search
393
+
394
+ - category: `collection_search`
395
+ - returns:
396
+ - envelope: `{ok, item, items, meta, error}`
397
+ - row_type: `collection`
398
+ - default_fields: `collection_id`, `slug`, `title`, `owner`, `owner_type`, `description`, `gating`, `last_updated`, `item_count`
399
+ - guaranteed_fields: `collection_id`, `title`, `owner`
400
+ - optional_fields: `slug`, `owner_type`, `description`, `gating`, `last_updated`, `item_count`
401
+ - supported_params: `query`, `owner`, `limit`, `count_only`, `where`, `fields`
402
+ - fields_contract:
403
+ - allowed_fields: `collection_id`, `slug`, `title`, `owner`, `owner_type`, `description`, `gating`, `last_updated`, `item_count`
404
+ - canonical_only: `true`
405
+ - where_contract:
406
+ - allowed_fields: `collection_id`, `slug`, `title`, `owner`, `owner_type`, `description`, `gating`, `last_updated`, `item_count`
407
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
408
+ - normalized_only: `true`
409
+ - limit_contract:
410
+ - default_limit: `20`
411
+ - max_limit: `500`
412
+ - notes: Collection summary helper.
413
+
414
+ ### hf_daily_papers
415
+
416
+ - category: `curated_feed`
417
+ - returns:
418
+ - envelope: `{ok, item, items, meta, error}`
419
+ - row_type: `daily_paper`
420
+ - default_fields: `paper_id`, `title`, `summary`, `published_at`, `submitted_on_daily_at`, `authors`, `organization`, `submitted_by`, `discussion_id`, `upvotes`, `github_repo_url`, `github_stars`, `project_page_url`, `num_comments`, `is_author_participating`, `repo_id`, `rank`
421
+ - guaranteed_fields: `paper_id`, `title`, `published_at`, `rank`
422
+ - optional_fields: `summary`, `submitted_on_daily_at`, `authors`, `organization`, `submitted_by`, `discussion_id`, `upvotes`, `github_repo_url`, `github_stars`, `project_page_url`, `num_comments`, `is_author_participating`, `repo_id`
423
+ - supported_params: `limit`, `where`, `fields`
424
+ - fields_contract:
425
+ - allowed_fields: `paper_id`, `title`, `summary`, `published_at`, `submitted_on_daily_at`, `authors`, `organization`, `submitted_by`, `discussion_id`, `upvotes`, `github_repo_url`, `github_stars`, `project_page_url`, `num_comments`, `is_author_participating`, `repo_id`, `rank`
426
+ - canonical_only: `true`
427
+ - where_contract:
428
+ - allowed_fields: `paper_id`, `title`, `summary`, `published_at`, `submitted_on_daily_at`, `authors`, `organization`, `submitted_by`, `discussion_id`, `upvotes`, `github_repo_url`, `github_stars`, `project_page_url`, `num_comments`, `is_author_participating`, `repo_id`, `rank`
429
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
430
+ - normalized_only: `true`
431
+ - limit_contract:
432
+ - default_limit: `20`
433
+ - max_limit: `500`
434
+ - notes: Returns daily paper summary rows. repo_id is omitted unless the upstream payload provides it.
435
+
436
+ ### hf_datasets_search
437
+
438
+ - category: `wrapped_hf_repo_search`
439
+ - backed_by: `HfApi.list_datasets`
440
+ - returns:
441
+ - envelope: `{ok, item, items, meta, error}`
442
+ - row_type: `repo`
443
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
444
+ - guaranteed_fields: `repo_id`, `repo_type`, `author`, `repo_url`
445
+ - optional_fields: `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
446
+ - supported_params: `search`, `filter`, `author`, `benchmark`, `dataset_name`, `gated`, `language_creators`, `language`, `multilinguality`, `size_categories`, `task_categories`, `task_ids`, `sort`, `limit`, `expand`, `full`, `fields`, `post_filter`
447
+ - sort_values: `created_at`, `downloads`, `last_modified`, `likes`, `trending_score`
448
+ - expand_values: `author`, `card_data`, `citation`, `created_at`, `description`, `disabled`, `downloads`, `downloads_all_time`, `gated`, `last_modified`, `likes`, `paperswithcode_id`, `private`, `resource_group`, `sha`, `siblings`, `tags`, `trending_score`, `xet_enabled`, `gitaly_uid`
449
+ - fields_contract:
450
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
451
+ - canonical_only: `true`
452
+ - post_filter_contract:
453
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
454
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
455
+ - normalized_only: `true`
456
+ - limit_contract:
457
+ - default_limit: `100`
458
+ - max_limit: `5000`
459
+ - notes: Thin dataset-search wrapper around the Hub list_datasets path. Prefer this over hf_repo_search for dataset-only queries. This is a one-shot selective search; if meta.limit_boundary_hit is true, more rows may exist and counts are not exact.
460
+
461
+ ### hf_models_search
462
+
463
+ - category: `wrapped_hf_repo_search`
464
+ - backed_by: `HfApi.list_models`
465
+ - returns:
466
+ - envelope: `{ok, item, items, meta, error}`
467
+ - row_type: `repo`
468
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
469
+ - guaranteed_fields: `repo_id`, `repo_type`, `author`, `repo_url`
470
+ - optional_fields: `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
471
+ - supported_params: `search`, `filter`, `author`, `apps`, `gated`, `inference`, `inference_provider`, `model_name`, `trained_dataset`, `pipeline_tag`, `num_params`, `emissions_thresholds`, `sort`, `limit`, `expand`, `full`, `card_data`, `fetch_config`, `fields`, `post_filter`
472
+ - sort_values: `created_at`, `downloads`, `last_modified`, `likes`, `trending_score`
473
+ - expand_values: `author`, `base_models`, `card_data`, `config`, `created_at`, `disabled`, `downloads`, `downloads_all_time`, `eval_results`, `gated`, `gguf`, `inference`, `inference_provider_mapping`, `last_modified`, `library_name`, `likes`, `mask_token`, `model_index`, `pipeline_tag`, `private`, `resource_group`, `safetensors`, `sha`, `siblings`, `spaces`, `tags`, `transformers_info`, `trending_score`, `widget_data`, `xet_enabled`, `gitaly_uid`
474
+ - fields_contract:
475
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
476
+ - canonical_only: `true`
477
+ - post_filter_contract:
478
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
479
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
480
+ - normalized_only: `true`
481
+ - limit_contract:
482
+ - default_limit: `100`
483
+ - max_limit: `5000`
484
+ - notes: Thin model-search wrapper around the Hub list_models path. Prefer this over hf_repo_search for model-only queries. This is a one-shot selective search; if meta.limit_boundary_hit is true, more rows may exist and counts are not exact.
485
+
486
+ ### hf_org_members
487
+
488
+ - category: `graph_scan`
489
+ - returns:
490
+ - envelope: `{ok, item, items, meta, error}`
491
+ - row_type: `actor`
492
+ - default_fields: `username`, `fullname`, `is_pro`, `role`, `type`
493
+ - guaranteed_fields: `username`
494
+ - optional_fields: `fullname`, `is_pro`, `role`, `type`
495
+ - supported_params: `organization`, `limit`, `scan_limit`, `count_only`, `where`, `fields`
496
+ - fields_contract:
497
+ - allowed_fields: `username`, `fullname`, `is_pro`, `role`, `type`
498
+ - canonical_only: `true`
499
+ - where_contract:
500
+ - allowed_fields: `username`, `fullname`, `is_pro`, `role`, `type`
501
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
502
+ - normalized_only: `true`
503
+ - limit_contract:
504
+ - default_limit: `1000`
505
+ - max_limit: `10000`
506
+ - scan_max: `10000`
507
+ - notes: Returns organization member summary rows.
508
+
509
+ ### hf_profile_summary
510
+
511
+ - category: `profile_summary`
512
+ - returns:
513
+ - envelope: `{ok, item, items, meta, error}`
514
+ - row_type: `profile`
515
+ - default_fields: `handle`, `entity_type`, `display_name`, `bio`, `description`, `avatar_url`, `website_url`, `twitter_url`, `github_url`, `linkedin_url`, `bluesky_url`, `followers_count`, `following_count`, `likes_count`, `members_count`, `models_count`, `datasets_count`, `spaces_count`, `discussions_count`, `papers_count`, `upvotes_count`, `organizations`, `is_pro`, `likes_sample`, `activity_sample`
516
+ - guaranteed_fields: `handle`, `entity_type`
517
+ - optional_fields: `display_name`, `bio`, `description`, `avatar_url`, `website_url`, `twitter_url`, `github_url`, `linkedin_url`, `bluesky_url`, `followers_count`, `following_count`, `likes_count`, `members_count`, `models_count`, `datasets_count`, `spaces_count`, `discussions_count`, `papers_count`, `upvotes_count`, `organizations`, `is_pro`, `likes_sample`, `activity_sample`
518
+ - supported_params: `handle`, `include`, `likes_limit`, `activity_limit`
519
+ - param_values:
520
+ - include: `likes`, `activity`
521
+ - notes: Profile summary helper. Aggregate counts like followers_count/following_count are in the base item. include=['likes', 'activity'] adds composed samples and extra upstream work; no other include values are supported. Overview-owned repo counts may differ slightly from visible public search/list results.
522
+
523
+ ### hf_recent_activity
524
+
525
+ - category: `activity_feed`
526
+ - returns:
527
+ - envelope: `{ok, item, items, meta, error}`
528
+ - row_type: `activity`
529
+ - default_fields: `event_type`, `repo_id`, `repo_type`, `timestamp`
530
+ - guaranteed_fields: `event_type`, `timestamp`
531
+ - optional_fields: `repo_id`, `repo_type`
532
+ - supported_params: `feed_type`, `entity`, `activity_types`, `repo_types`, `limit`, `max_pages`, `start_cursor`, `count_only`, `where`, `fields`
533
+ - param_values:
534
+ - feed_type: `user`, `org`
535
+ - repo_types: `model`, `dataset`, `space`
536
+ - fields_contract:
537
+ - allowed_fields: `event_type`, `repo_id`, `repo_type`, `timestamp`
538
+ - canonical_only: `true`
539
+ - where_contract:
540
+ - allowed_fields: `event_type`, `repo_id`, `repo_type`, `timestamp`
541
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
542
+ - normalized_only: `true`
543
+ - limit_contract:
544
+ - default_limit: `100`
545
+ - max_limit: `2000`
546
+ - max_pages: `10`
547
+ - page_limit: `100`
548
+ - notes: Activity helper may fetch multiple pages when requested coverage exceeds one page. count_only may still be a lower bound unless the feed exhausts before max_pages.
549
+
550
+ ### hf_repo_details
551
+
552
+ - category: `repo_detail`
553
+ - returns:
554
+ - envelope: `{ok, item, items, meta, error}`
555
+ - row_type: `repo`
556
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
557
+ - guaranteed_fields: `repo_id`, `repo_type`, `author`, `repo_url`
558
+ - optional_fields: `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
559
+ - supported_params: `repo_id`, `repo_ids`, `repo_type`, `fields`
560
+ - param_values:
561
+ - repo_type: `model`, `dataset`, `space`, `auto`
562
+ - fields_contract:
563
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
564
+ - canonical_only: `true`
565
+ - notes: Exact repo metadata path. Multiple repo_ids may trigger one detail call per requested repo.
566
+
567
+ ### hf_repo_discussion_details
568
+
569
+ - category: `discussion_detail`
570
+ - returns:
571
+ - envelope: `{ok, item, items, meta, error}`
572
+ - row_type: `discussion_detail`
573
+ - default_fields: `num`, `repo_id`, `repo_type`, `title`, `author`, `created_at`, `status`, `url`, `comment_count`, `latest_comment_author`, `latest_comment_created_at`, `latest_comment_text`, `latest_comment_html`
574
+ - guaranteed_fields: `repo_id`, `repo_type`, `title`, `author`, `status`
575
+ - optional_fields: `num`, `created_at`, `url`, `comment_count`, `latest_comment_author`, `latest_comment_created_at`, `latest_comment_text`, `latest_comment_html`
576
+ - supported_params: `repo_type`, `repo_id`, `discussion_num`, `fields`
577
+ - param_values:
578
+ - repo_type: `model`, `dataset`, `space`
579
+ - fields_contract:
580
+ - allowed_fields: `num`, `repo_id`, `repo_type`, `title`, `author`, `created_at`, `status`, `url`, `comment_count`, `latest_comment_author`, `latest_comment_created_at`, `latest_comment_text`, `latest_comment_html`
581
+ - canonical_only: `true`
582
+ - notes: Exact discussion detail helper.
583
+
584
+ ### hf_repo_discussions
585
+
586
+ - category: `discussion_summary`
587
+ - returns:
588
+ - envelope: `{ok, item, items, meta, error}`
589
+ - row_type: `discussion`
590
+ - default_fields: `num`, `repo_id`, `repo_type`, `title`, `author`, `created_at`, `status`, `url`
591
+ - guaranteed_fields: `num`, `title`, `author`, `status`
592
+ - optional_fields: `repo_id`, `repo_type`, `created_at`, `url`
593
+ - supported_params: `repo_type`, `repo_id`, `limit`, `fields`
594
+ - param_values:
595
+ - repo_type: `model`, `dataset`, `space`
596
+ - fields_contract:
597
+ - allowed_fields: `num`, `repo_id`, `repo_type`, `title`, `author`, `created_at`, `status`, `url`
598
+ - canonical_only: `true`
599
+ - limit_contract:
600
+ - default_limit: `20`
601
+ - max_limit: `200`
602
+ - notes: Discussion summary helper.
603
+
604
+ ### hf_repo_likers
605
+
606
+ - category: `repo_to_users`
607
+ - returns:
608
+ - envelope: `{ok, item, items, meta, error}`
609
+ - row_type: `actor`
610
+ - default_fields: `username`, `fullname`, `is_pro`, `role`, `type`
611
+ - guaranteed_fields: `username`
612
+ - optional_fields: `fullname`, `is_pro`, `role`, `type`
613
+ - supported_params: `repo_id`, `repo_type`, `limit`, `count_only`, `pro_only`, `where`, `fields`
614
+ - param_values:
615
+ - repo_type: `model`, `dataset`, `space`
616
+ - fields_contract:
617
+ - allowed_fields: `username`, `fullname`, `is_pro`, `role`, `type`
618
+ - canonical_only: `true`
619
+ - where_contract:
620
+ - allowed_fields: `username`, `fullname`, `is_pro`, `role`, `type`
621
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
622
+ - normalized_only: `true`
623
+ - limit_contract:
624
+ - default_limit: `1000`
625
+ - notes: Returns users who liked a repo.
626
+
627
+ ### hf_repo_search
628
+
629
+ - category: `cross_type_repo_search`
630
+ - returns:
631
+ - envelope: `{ok, item, items, meta, error}`
632
+ - row_type: `repo`
633
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
634
+ - guaranteed_fields: `repo_id`, `repo_type`, `author`, `repo_url`
635
+ - optional_fields: `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
636
+ - supported_params: `search`, `repo_type`, `repo_types`, `filter`, `author`, `sort`, `limit`, `fields`, `post_filter`
637
+ - sort_values_by_repo_type:
638
+ - dataset: `created_at`, `downloads`, `last_modified`, `likes`, `trending_score`
639
+ - model: `created_at`, `downloads`, `last_modified`, `likes`, `trending_score`
640
+ - space: `created_at`, `last_modified`, `likes`, `trending_score`
641
+ - param_values:
642
+ - repo_type: `model`, `dataset`, `space`
643
+ - repo_types: `model`, `dataset`, `space`
644
+ - sort: `created_at`, `downloads`, `last_modified`, `likes`, `trending_score`
645
+ - fields_contract:
646
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
647
+ - canonical_only: `true`
648
+ - post_filter_contract:
649
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
650
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
651
+ - normalized_only: `true`
652
+ - limit_contract:
653
+ - default_limit: `100`
654
+ - max_limit: `5000`
655
+ - notes: Small generic repo-search helper. Prefer hf_models_search, hf_datasets_search, or hf_spaces_search for single-type queries; use hf_repo_search for intentionally cross-type search. This is a one-shot selective search; if meta.limit_boundary_hit is true, more rows may exist and counts are not exact.
656
+
657
+ ### hf_runtime_capabilities
658
+
659
+ - category: `introspection`
660
+ - returns:
661
+ - envelope: `{ok, item, items, meta, error}`
662
+ - row_type: `runtime_capability`
663
+ - default_fields: `allowed_sections`, `overview`, `helpers`, `helper_contracts`, `helper_defaults`, `fields`, `limits`, `repo_search`
664
+ - guaranteed_fields: `allowed_sections`, `overview`, `helpers`, `helper_contracts`, `helper_defaults`, `fields`, `limits`, `repo_search`
665
+ - optional_fields: []
666
+ - supported_params: `section`
667
+ - param_values:
668
+ - section: `overview`, `helpers`, `helper_contracts`, `helper_defaults`, `fields`, `limits`, `repo_search`
669
+ - notes: Introspection helper. Use section=... to narrow the response.
670
+
671
+ ### hf_spaces_search
672
+
673
+ - category: `wrapped_hf_repo_search`
674
+ - backed_by: `HfApi.list_spaces`
675
+ - returns:
676
+ - envelope: `{ok, item, items, meta, error}`
677
+ - row_type: `repo`
678
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
679
+ - guaranteed_fields: `repo_id`, `repo_type`, `author`, `repo_url`
680
+ - optional_fields: `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
681
+ - supported_params: `search`, `filter`, `author`, `datasets`, `models`, `linked`, `sort`, `limit`, `expand`, `full`, `fields`, `post_filter`
682
+ - sort_values: `created_at`, `last_modified`, `likes`, `trending_score`
683
+ - expand_values: `author`, `card_data`, `created_at`, `datasets`, `disabled`, `last_modified`, `likes`, `models`, `private`, `resource_group`, `runtime`, `sdk`, `sha`, `siblings`, `subdomain`, `tags`, `trending_score`, `xet_enabled`, `gitaly_uid`
684
+ - fields_contract:
685
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
686
+ - canonical_only: `true`
687
+ - post_filter_contract:
688
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
689
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
690
+ - normalized_only: `true`
691
+ - limit_contract:
692
+ - default_limit: `100`
693
+ - max_limit: `5000`
694
+ - notes: Thin space-search wrapper around the Hub list_spaces path. Prefer this over hf_repo_search for space-only queries. This is a one-shot selective search; if meta.limit_boundary_hit is true, more rows may exist and counts are not exact.
695
+
696
+ ### hf_trending
697
+
698
+ - category: `curated_repo_feed`
699
+ - returns:
700
+ - envelope: `{ok, item, items, meta, error}`
701
+ - row_type: `repo`
702
+ - default_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`, `trending_rank`
703
+ - guaranteed_fields: `repo_id`, `repo_type`, `author`, `repo_url`, `trending_rank`
704
+ - optional_fields: `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`
705
+ - supported_params: `repo_type`, `limit`, `where`, `fields`
706
+ - param_values:
707
+ - repo_type: `model`, `dataset`, `space`, `all`
708
+ - fields_contract:
709
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`, `trending_rank`
710
+ - canonical_only: `true`
711
+ - where_contract:
712
+ - allowed_fields: `repo_id`, `repo_type`, `author`, `likes`, `downloads`, `trending_score`, `created_at`, `last_modified`, `pipeline_tag`, `num_params`, `repo_url`, `tags`, `library_name`, `description`, `paperswithcode_id`, `sdk`, `models`, `datasets`, `subdomain`, `runtime_stage`, `runtime`, `trending_rank`
713
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
714
+ - normalized_only: `true`
715
+ - limit_contract:
716
+ - default_limit: `20`
717
+ - max_limit: `20`
718
+ - notes: Returns ordered trending summary rows only. Use hf_repo_details for exact repo metadata.
719
+
720
+ ### hf_user_graph
721
+
722
+ - category: `graph_scan`
723
+ - returns:
724
+ - envelope: `{ok, item, items, meta, error}`
725
+ - row_type: `actor`
726
+ - default_fields: `username`, `fullname`, `is_pro`, `role`, `type`
727
+ - guaranteed_fields: `username`
728
+ - optional_fields: `fullname`, `is_pro`, `role`, `type`
729
+ - supported_params: `username`, `relation`, `limit`, `scan_limit`, `count_only`, `pro_only`, `where`, `fields`
730
+ - param_values:
731
+ - relation: `followers`, `following`
732
+ - fields_contract:
733
+ - allowed_fields: `username`, `fullname`, `is_pro`, `role`, `type`
734
+ - canonical_only: `true`
735
+ - where_contract:
736
+ - allowed_fields: `username`, `fullname`, `is_pro`, `role`, `type`
737
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
738
+ - normalized_only: `true`
739
+ - limit_contract:
740
+ - default_limit: `1000`
741
+ - max_limit: `10000`
742
+ - scan_max: `10000`
743
+ - notes: Returns followers/following summary rows.
744
+
745
+ ### hf_user_likes
746
+
747
+ - category: `user_to_repos`
748
+ - returns:
749
+ - envelope: `{ok, item, items, meta, error}`
750
+ - row_type: `user_like`
751
+ - default_fields: `liked_at`, `repo_id`, `repo_type`, `repo_author`, `repo_likes`, `repo_downloads`, `repo_url`
752
+ - guaranteed_fields: `liked_at`, `repo_id`, `repo_type`
753
+ - optional_fields: `repo_author`, `repo_likes`, `repo_downloads`, `repo_url`
754
+ - supported_params: `username`, `repo_types`, `limit`, `scan_limit`, `count_only`, `where`, `fields`, `sort`, `ranking_window`
755
+ - sort_values: `liked_at`, `repo_likes`, `repo_downloads`
756
+ - param_values:
757
+ - repo_types: `model`, `dataset`, `space`
758
+ - sort: `liked_at`, `repo_likes`, `repo_downloads`
759
+ - fields_contract:
760
+ - allowed_fields: `liked_at`, `repo_id`, `repo_type`, `repo_author`, `repo_likes`, `repo_downloads`, `repo_url`
761
+ - canonical_only: `true`
762
+ - where_contract:
763
+ - allowed_fields: `liked_at`, `repo_id`, `repo_type`, `repo_author`, `repo_likes`, `repo_downloads`, `repo_url`
764
+ - supported_ops: `eq`, `in`, `contains`, `icontains`, `gte`, `lte`
765
+ - normalized_only: `true`
766
+ - limit_contract:
767
+ - default_limit: `100`
768
+ - max_limit: `2000`
769
+ - enrich_max: `50`
770
+ - ranking_default: `50`
771
+ - scan_max: `10000`
772
+ - notes: Default recency mode is cheap. Popularity-ranked sorts use canonical keys liked_at/repo_likes/repo_downloads and rerank only a bounded recent shortlist. Check meta.ranking_complete / meta.ranking_window when ranking by popularity; helper-owned coverage matters here.
773
+
774
+ ### hf_whoami
775
+
776
+ - category: `identity`
777
+ - returns:
778
+ - envelope: `{ok, item, items, meta, error}`
779
+ - row_type: `user`
780
+ - default_fields: `username`, `fullname`, `is_pro`
781
+ - guaranteed_fields: `username`
782
+ - optional_fields: `fullname`, `is_pro`
783
+ - supported_params: []
784
+ - notes: Returns the current authenticated user when a request token is available.
hf-hub-query.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ type: agent
3
+ name: hf_hub_query
4
+ model: hf.openai/gpt-oss-120b:sambanova
5
+ use_history: false
6
+ default: true
7
+ description: "Read-only Hugging Face Hub navigator for discovery, lookup, filtering, ranking, counts, field-constrained extraction, and relationship questions across users, orgs, models, datasets, spaces, collections, discussions, daily papers, recent activity, followers/following, likes, and likers. Good for structured raw outputs and compact results. Generated helper calls can explicitly bound limit, scan_limit, max_pages, and ranking_window for brevity or broader coverage, and the tool can also be asked about its supported helpers, canonical fields, defaults, and coverage behavior."
8
+ shell: false
9
+ skills: []
10
+ #tool_hooks:
11
+ # after_llm_call: monty_api/llm_time_hook.py:display_llm_time
12
+ function_tools:
13
+ - entrypoint: tool_entrypoints.py:hf_hub_query_raw
14
+ variant: code
15
+ code_arg: code
16
+ language: python
17
+ request_params:
18
+ tool_result_mode: passthrough
19
+ ---
20
+
21
+ reasoning: medium
22
+
23
+ You are a **tool-using, read-only** Hugging Face Hub search/navigation agent.
24
+ The user must never see your generated Python unless they explicitly ask for debugging.
25
+
26
+ ## Turn protocol
27
+ - For normal requests, your **first assistant action must be exactly one tool call** to `hf_hub_query_raw`.
28
+ - Put the generated Python only in the tool's `code` argument.
29
+ - Do **not** output planning text, pseudocode, code fences, or contract explanations before the tool call.
30
+ - Only ask a brief clarification question if the request is genuinely ambiguous or missing required identity.
31
+ - The generated program is a top-level Monty Python script.
32
+ - `max_calls` is provided by the runtime as a top-level input.
33
+ - Always assign the final output to `result`.
34
+ - The final line must be exactly `result`.
35
+ - Never stop after `result = ...`; always add a final bare `result` line.
36
+ - Do **not** define or call `solve(...)`.
37
+ - The tool call only needs `code` unless you truly need optional raw-query metadata.
38
+ - Do **not** pass explicit `max_calls` or `timeout_sec` tool arguments unless the user explicitly asked for a non-default budget/timeout. Let the runtime defaults apply for ordinary requests.
39
+ - One user request = one `hf_hub_query_raw` call. Do **not** retry in the same turn.
40
+
41
+ ## Raw return rules
42
+ - The value of `result` is the user-facing payload.
43
+ - Make `result` a dict/list when JSON is appropriate; use a string/number/bool only when that scalar is the intended payload.
44
+ - For composed structured outputs that include your own coverage metadata, always use the exact top-level keys `results` and `coverage` unless the user explicitly asked for different key names.
45
+ - Prefer emitting outputs directly unless post-processing is required. Do **NOT** rename fields unless asked specifically.
46
+ - Runtime will wrap the value of `result` under `result` and attach runtime information under `meta`.
47
+ - When helper-owned coverage metadata matters, prefer returning the helper envelope directly.
48
+ - Do **not** create your own transport wrapper such as `{result: ..., meta: ...}` in generated code.
49
+
50
+ {{file:_monty_codegen_shared.md}}
monty_api/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .registry import HELPER_EXTERNALS
4
+
5
+
6
+ def __getattr__(name: str): # pragma: no cover - tiny import shim
7
+ if name in {"hf_hub_query", "hf_hub_query_raw", "main"}:
8
+ from .query_entrypoints import hf_hub_query, hf_hub_query_raw, main
9
+
10
+ exports = {
11
+ "hf_hub_query": hf_hub_query,
12
+ "hf_hub_query_raw": hf_hub_query_raw,
13
+ "main": main,
14
+ }
15
+ return exports[name]
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
17
+
18
+ __all__ = [
19
+ "HELPER_EXTERNALS",
20
+ "hf_hub_query",
21
+ "hf_hub_query_raw",
22
+ "main",
23
+ ]
monty_api/aliases.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import get_args
4
+
5
+ try:
6
+ from huggingface_hub.hf_api import DatasetSort_T, ModelSort_T, SpaceSort_T
7
+ except ModuleNotFoundError: # pragma: no cover - dependency-light test/import path
8
+ DatasetSort_T = ()
9
+ ModelSort_T = ()
10
+ SpaceSort_T = ()
11
+
12
+ REPO_SORT_KEYS: dict[str, set[str]] = {
13
+ "model": set(get_args(ModelSort_T))
14
+ or {
15
+ "created_at",
16
+ "downloads",
17
+ "last_modified",
18
+ "likes",
19
+ "trending_score",
20
+ },
21
+ "dataset": set(get_args(DatasetSort_T))
22
+ or {
23
+ "created_at",
24
+ "downloads",
25
+ "last_modified",
26
+ "likes",
27
+ "trending_score",
28
+ },
29
+ "space": set(get_args(SpaceSort_T))
30
+ or {
31
+ "created_at",
32
+ "last_modified",
33
+ "likes",
34
+ "trending_score",
35
+ },
36
+ }
monty_api/constants.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_TIMEOUT_SEC = 90 # Default end-to-end timeout for one Monty run.
4
+
5
+ DEFAULT_MAX_CALLS = 400 # Default external-call budget exposed to callers.
6
+
7
+ MAX_CALLS_LIMIT = 400 # Absolute max external-call budget accepted by the runtime.
8
+
9
+ INTERNAL_STRICT_MODE = False
10
+
11
+ OUTPUT_ITEMS_TRUNCATION_LIMIT = (
12
+ 500 # Final output truncation for oversized `items` payloads.
13
+ )
14
+
15
+ EXHAUSTIVE_HELPER_RETURN_HARD_CAP = (
16
+ 2_000 # Runtime hard cap for exhaustive-helper output rows.
17
+ )
18
+
19
+ SELECTIVE_ENDPOINT_RETURN_HARD_CAP = (
20
+ 200 # Default cap for one-shot selective endpoint helpers.
21
+ )
22
+
23
+ TRENDING_ENDPOINT_MAX_LIMIT = 20 # Upstream `/api/trending` endpoint maximum.
24
+
25
+ GRAPH_SCAN_LIMIT_CAP = 10_000 # Max follower/member rows scanned in one helper call.
26
+
27
+ LIKES_SCAN_LIMIT_CAP = 10_000 # Max like-event rows scanned in one helper call.
28
+
29
+ LIKES_RANKING_WINDOW_DEFAULT = (
30
+ 50 # Default shortlist size when ranking likes by repo popularity.
31
+ )
32
+
33
+ LIKES_ENRICHMENT_MAX_REPOS = (
34
+ 50 # Max liked repos enriched with extra repo-detail calls.
35
+ )
36
+
37
+ RECENT_ACTIVITY_PAGE_SIZE = 100 # Rows requested per `/api/recent-activity` page.
38
+
39
+ RECENT_ACTIVITY_SCAN_MAX_PAGES = (
40
+ 10 # Max recent-activity pages fetched in one helper call.
41
+ )
42
+
43
+ USER_SUMMARY_LIKES_SCAN_LIMIT = 1_000 # Like rows sampled for user summary.
44
+
45
+ USER_SUMMARY_ACTIVITY_MAX_PAGES = 3 # Activity pages sampled for user summary.
46
+
47
+ DEFAULT_MONTY_MAX_MEMORY = 64 * 1024 * 1024 # 64 MiB
48
+
49
+ DEFAULT_MONTY_MAX_ALLOCATIONS = (
50
+ 250_000 # Approximate object-allocation ceiling in the sandbox.
51
+ )
52
+
53
+ DEFAULT_MONTY_MAX_RECURSION_DEPTH = 100 # Python recursion limit inside the sandbox.
54
+
55
+ REPO_CANONICAL_FIELDS: tuple[str, ...] = (
56
+ "repo_id",
57
+ "repo_type",
58
+ "author",
59
+ "likes",
60
+ "downloads",
61
+ "trending_score",
62
+ "created_at",
63
+ "last_modified",
64
+ "pipeline_tag",
65
+ "num_params",
66
+ "repo_url",
67
+ "tags",
68
+ "library_name",
69
+ "description",
70
+ "paperswithcode_id",
71
+ "sdk",
72
+ "models",
73
+ "datasets",
74
+ "subdomain",
75
+ "runtime_stage",
76
+ "runtime",
77
+ )
78
+
79
+ USER_CANONICAL_FIELDS: tuple[str, ...] = (
80
+ "username",
81
+ "fullname",
82
+ "bio",
83
+ "website_url",
84
+ "twitter",
85
+ "github",
86
+ "linkedin",
87
+ "bluesky",
88
+ "followers",
89
+ "following",
90
+ "likes",
91
+ "is_pro",
92
+ )
93
+
94
+ PROFILE_CANONICAL_FIELDS: tuple[str, ...] = (
95
+ "handle",
96
+ "entity_type",
97
+ "display_name",
98
+ "bio",
99
+ "description",
100
+ "avatar_url",
101
+ "website_url",
102
+ "twitter_url",
103
+ "github_url",
104
+ "linkedin_url",
105
+ "bluesky_url",
106
+ "followers_count",
107
+ "following_count",
108
+ "likes_count",
109
+ "members_count",
110
+ "models_count",
111
+ "datasets_count",
112
+ "spaces_count",
113
+ "discussions_count",
114
+ "papers_count",
115
+ "upvotes_count",
116
+ "organizations",
117
+ "is_pro",
118
+ "likes_sample",
119
+ "activity_sample",
120
+ )
121
+
122
+ ACTOR_CANONICAL_FIELDS: tuple[str, ...] = (
123
+ "username",
124
+ "fullname",
125
+ "is_pro",
126
+ "role",
127
+ "type",
128
+ )
129
+
130
+ USER_LIKES_CANONICAL_FIELDS: tuple[str, ...] = (
131
+ "liked_at",
132
+ "repo_id",
133
+ "repo_type",
134
+ "repo_author",
135
+ "repo_likes",
136
+ "repo_downloads",
137
+ "repo_url",
138
+ )
139
+
140
+ DISCUSSION_CANONICAL_FIELDS: tuple[str, ...] = (
141
+ "num",
142
+ "repo_id",
143
+ "repo_type",
144
+ "title",
145
+ "author",
146
+ "created_at",
147
+ "status",
148
+ "url",
149
+ )
150
+
151
+ DISCUSSION_DETAIL_CANONICAL_FIELDS: tuple[str, ...] = (
152
+ "num",
153
+ "repo_id",
154
+ "repo_type",
155
+ "title",
156
+ "author",
157
+ "created_at",
158
+ "status",
159
+ "url",
160
+ "comment_count",
161
+ "latest_comment_author",
162
+ "latest_comment_created_at",
163
+ "latest_comment_text",
164
+ "latest_comment_html",
165
+ )
166
+
167
+ ACTIVITY_CANONICAL_FIELDS: tuple[str, ...] = (
168
+ "event_type",
169
+ "repo_id",
170
+ "repo_type",
171
+ "timestamp",
172
+ )
173
+
174
+ COLLECTION_CANONICAL_FIELDS: tuple[str, ...] = (
175
+ "collection_id",
176
+ "slug",
177
+ "title",
178
+ "owner",
179
+ "owner_type",
180
+ "description",
181
+ "gating",
182
+ "last_updated",
183
+ "item_count",
184
+ )
185
+
186
+ DAILY_PAPER_CANONICAL_FIELDS: tuple[str, ...] = (
187
+ "paper_id",
188
+ "title",
189
+ "summary",
190
+ "published_at",
191
+ "submitted_on_daily_at",
192
+ "authors",
193
+ "organization",
194
+ "submitted_by",
195
+ "discussion_id",
196
+ "upvotes",
197
+ "github_repo_url",
198
+ "github_stars",
199
+ "project_page_url",
200
+ "num_comments",
201
+ "is_author_participating",
202
+ "repo_id",
203
+ "rank",
204
+ )
monty_api/context_types.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+
6
+ class HelperRuntimeContext(Protocol):
7
+ """Typed helper-facing runtime context interface."""
8
+
9
+ helper_registry: dict[str, Any]
10
+ call_count: dict[str, int]
11
+ trace: list[dict[str, Any]]
12
+ limit_summaries: list[dict[str, Any]]
13
+ latest_helper_error_box: dict[str, dict[str, Any] | None]
14
+ internal_helper_used: dict[str, bool]
15
+
16
+ async def call_helper(
17
+ self, helper_name: str, /, *args: Any, **kwargs: Any
18
+ ) -> Any: ...
19
+
20
+ def __getattr__(self, name: str) -> Any: ...
monty_api/helper_contracts.py ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import re
5
+ from collections.abc import Callable, Mapping
6
+ from functools import lru_cache
7
+ from typing import Any, TypedDict, get_args
8
+
9
+ try:
10
+ import huggingface_hub.hf_api as hf_api
11
+ except ModuleNotFoundError: # pragma: no cover - dependency-light test/import path
12
+ hf_api = None
13
+
14
+ from .aliases import REPO_SORT_KEYS
15
+ from .constants import (
16
+ ACTIVITY_CANONICAL_FIELDS,
17
+ ACTOR_CANONICAL_FIELDS,
18
+ COLLECTION_CANONICAL_FIELDS,
19
+ DAILY_PAPER_CANONICAL_FIELDS,
20
+ DISCUSSION_CANONICAL_FIELDS,
21
+ DISCUSSION_DETAIL_CANONICAL_FIELDS,
22
+ PROFILE_CANONICAL_FIELDS,
23
+ REPO_CANONICAL_FIELDS,
24
+ USER_CANONICAL_FIELDS,
25
+ USER_LIKES_CANONICAL_FIELDS,
26
+ )
27
+ from .registry import (
28
+ HELPER_DEFAULT_METADATA,
29
+ PAGINATION_POLICY,
30
+ REPO_SEARCH_ALLOWED_EXPAND,
31
+ RUNTIME_CAPABILITY_FIELDS,
32
+ )
33
+
34
+
35
+ HELPER_RESULT_ENVELOPE = {
36
+ "ok": "bool",
37
+ "item": "dict | None",
38
+ "items": "list[dict]",
39
+ "meta": "dict",
40
+ "error": "str | None",
41
+ }
42
+
43
+ FILTER_OPERATORS = ["eq", "in", "contains", "icontains", "gte", "lte"]
44
+ REPO_TYPE_VALUES = ["model", "dataset", "space"]
45
+ TRENDING_CANONICAL_FIELDS = [*REPO_CANONICAL_FIELDS, "trending_rank"]
46
+ COMMON_REPO_SEARCH_PARAMS = {
47
+ "search",
48
+ "filter",
49
+ "author",
50
+ "sort",
51
+ "limit",
52
+ "fields",
53
+ "post_filter",
54
+ }
55
+
56
+
57
+ class HelperContract(TypedDict, total=False):
58
+ name: str
59
+ signature: str
60
+ category: str
61
+ backed_by: str
62
+ supported_params: list[str]
63
+ sort_values: list[str]
64
+ sort_values_by_repo_type: dict[str, list[str]]
65
+ expand_values: list[str]
66
+ param_values: dict[str, list[str]]
67
+ fields_contract: dict[str, Any]
68
+ where_contract: dict[str, Any]
69
+ post_filter_contract: dict[str, Any]
70
+ limit_contract: dict[str, Any]
71
+ returns: dict[str, Any]
72
+ notes: str
73
+
74
+
75
+ FIELD_GROUPS: dict[str, list[str]] = {
76
+ "activity": list(ACTIVITY_CANONICAL_FIELDS),
77
+ "actor": list(ACTOR_CANONICAL_FIELDS),
78
+ "collection": list(COLLECTION_CANONICAL_FIELDS),
79
+ "daily_paper": list(DAILY_PAPER_CANONICAL_FIELDS),
80
+ "discussion": list(DISCUSSION_CANONICAL_FIELDS),
81
+ "discussion_detail": list(DISCUSSION_DETAIL_CANONICAL_FIELDS),
82
+ "profile": list(PROFILE_CANONICAL_FIELDS),
83
+ "repo": list(REPO_CANONICAL_FIELDS),
84
+ "trending_repo": list(TRENDING_CANONICAL_FIELDS),
85
+ "runtime_capability": list(RUNTIME_CAPABILITY_FIELDS),
86
+ "user": list(USER_CANONICAL_FIELDS),
87
+ "user_like": list(USER_LIKES_CANONICAL_FIELDS),
88
+ }
89
+ RUNTIME_CAPABILITY_SECTION_VALUES = [
90
+ field for field in RUNTIME_CAPABILITY_FIELDS if field != "allowed_sections"
91
+ ]
92
+
93
+
94
+ HELPER_CONTRACT_SPECS: dict[str, dict[str, Any]] = {
95
+ "hf_collection_items": {
96
+ "category": "collection_navigation",
97
+ "row_type": "repo",
98
+ "fields_group": "repo",
99
+ "filter_param": "where",
100
+ "filter_group": "repo",
101
+ "param_values": {"repo_types": REPO_TYPE_VALUES},
102
+ },
103
+ "hf_collections_search": {
104
+ "category": "collection_search",
105
+ "row_type": "collection",
106
+ "fields_group": "collection",
107
+ "filter_param": "where",
108
+ "filter_group": "collection",
109
+ },
110
+ "hf_daily_papers": {
111
+ "category": "curated_feed",
112
+ "row_type": "daily_paper",
113
+ "fields_group": "daily_paper",
114
+ "filter_param": "where",
115
+ "filter_group": "daily_paper",
116
+ },
117
+ "hf_datasets_search": {
118
+ "category": "wrapped_hf_repo_search",
119
+ "row_type": "repo",
120
+ "fields_group": "repo",
121
+ "filter_param": "post_filter",
122
+ "filter_group": "repo",
123
+ "upstream_repo_type": "dataset",
124
+ },
125
+ "hf_models_search": {
126
+ "category": "wrapped_hf_repo_search",
127
+ "row_type": "repo",
128
+ "fields_group": "repo",
129
+ "filter_param": "post_filter",
130
+ "filter_group": "repo",
131
+ "upstream_repo_type": "model",
132
+ },
133
+ "hf_org_members": {
134
+ "category": "graph_scan",
135
+ "row_type": "actor",
136
+ "fields_group": "actor",
137
+ "filter_param": "where",
138
+ "filter_group": "actor",
139
+ },
140
+ "hf_profile_summary": {
141
+ "category": "profile_summary",
142
+ "row_type": "profile",
143
+ "param_values": {"include": ["likes", "activity"]},
144
+ },
145
+ "hf_recent_activity": {
146
+ "category": "activity_feed",
147
+ "row_type": "activity",
148
+ "fields_group": "activity",
149
+ "filter_param": "where",
150
+ "filter_group": "activity",
151
+ "param_values": {"feed_type": ["user", "org"], "repo_types": REPO_TYPE_VALUES},
152
+ },
153
+ "hf_repo_details": {
154
+ "category": "repo_detail",
155
+ "row_type": "repo",
156
+ "fields_group": "repo",
157
+ "param_values": {"repo_type": [*REPO_TYPE_VALUES, "auto"]},
158
+ },
159
+ "hf_repo_discussion_details": {
160
+ "category": "discussion_detail",
161
+ "row_type": "discussion_detail",
162
+ "fields_group": "discussion_detail",
163
+ "param_values": {"repo_type": REPO_TYPE_VALUES},
164
+ },
165
+ "hf_repo_discussions": {
166
+ "category": "discussion_summary",
167
+ "row_type": "discussion",
168
+ "fields_group": "discussion",
169
+ "param_values": {"repo_type": REPO_TYPE_VALUES},
170
+ },
171
+ "hf_repo_likers": {
172
+ "category": "repo_to_users",
173
+ "row_type": "actor",
174
+ "fields_group": "actor",
175
+ "filter_param": "where",
176
+ "filter_group": "actor",
177
+ "param_values": {"repo_type": REPO_TYPE_VALUES},
178
+ },
179
+ "hf_repo_search": {
180
+ "category": "cross_type_repo_search",
181
+ "row_type": "repo",
182
+ "fields_group": "repo",
183
+ "filter_param": "post_filter",
184
+ "filter_group": "repo",
185
+ "param_values": {"repo_type": REPO_TYPE_VALUES, "repo_types": REPO_TYPE_VALUES},
186
+ },
187
+ "hf_runtime_capabilities": {
188
+ "category": "introspection",
189
+ "row_type": "runtime_capability",
190
+ "param_values": {"section": list(RUNTIME_CAPABILITY_SECTION_VALUES)},
191
+ },
192
+ "hf_spaces_search": {
193
+ "category": "wrapped_hf_repo_search",
194
+ "row_type": "repo",
195
+ "fields_group": "repo",
196
+ "filter_param": "post_filter",
197
+ "filter_group": "repo",
198
+ "upstream_repo_type": "space",
199
+ },
200
+ "hf_trending": {
201
+ "category": "curated_repo_feed",
202
+ "row_type": "repo",
203
+ "fields_group": "trending_repo",
204
+ "filter_param": "where",
205
+ "filter_group": "trending_repo",
206
+ "param_values": {"repo_type": [*REPO_TYPE_VALUES, "all"]},
207
+ },
208
+ "hf_user_graph": {
209
+ "category": "graph_scan",
210
+ "row_type": "actor",
211
+ "fields_group": "actor",
212
+ "filter_param": "where",
213
+ "filter_group": "actor",
214
+ "param_values": {
215
+ "relation": ["followers", "following"],
216
+ },
217
+ },
218
+ "hf_user_likes": {
219
+ "category": "user_to_repos",
220
+ "row_type": "user_like",
221
+ "fields_group": "user_like",
222
+ "filter_param": "where",
223
+ "filter_group": "user_like",
224
+ "param_values": {
225
+ "repo_types": REPO_TYPE_VALUES,
226
+ "sort": ["liked_at", "repo_likes", "repo_downloads"],
227
+ },
228
+ },
229
+ "hf_whoami": {
230
+ "category": "identity",
231
+ "row_type": "user",
232
+ },
233
+ }
234
+
235
+
236
+ def _dedupe(values: list[str]) -> list[str]:
237
+ seen: set[str] = set()
238
+ out: list[str] = []
239
+ for value in values:
240
+ item = str(value).strip()
241
+ if not item or item in seen:
242
+ continue
243
+ seen.add(item)
244
+ out.append(item)
245
+ return out
246
+
247
+
248
+ def _snake_case_token(value: str) -> str:
249
+ cleaned = str(value).strip().replace("-", "_")
250
+ cleaned = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", cleaned)
251
+ cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", cleaned)
252
+ cleaned = re.sub(r"__+", "_", cleaned)
253
+ return cleaned.lower()
254
+
255
+
256
+ def repo_expand_alias_map(repo_type: str) -> dict[str, str]:
257
+ aliases: dict[str, str] = {}
258
+ for raw_value in REPO_SEARCH_ALLOWED_EXPAND.get(repo_type, []):
259
+ aliases[str(raw_value)] = str(raw_value)
260
+ aliases[_snake_case_token(str(raw_value))] = str(raw_value)
261
+ return aliases
262
+
263
+
264
+ def normalized_repo_expand_values(repo_type: str) -> list[str]:
265
+ return _dedupe(
266
+ [
267
+ _snake_case_token(value)
268
+ for value in REPO_SEARCH_ALLOWED_EXPAND.get(repo_type, [])
269
+ ]
270
+ )
271
+
272
+
273
+ @lru_cache(maxsize=1)
274
+ def _upstream_repo_search_facts() -> dict[str, dict[str, Any]]:
275
+ alias_names = {
276
+ "dataset": ("list_datasets", "DatasetSort_T"),
277
+ "model": ("list_models", "ModelSort_T"),
278
+ "space": ("list_spaces", "SpaceSort_T"),
279
+ }
280
+ facts: dict[str, dict[str, Any]] = {}
281
+ for repo_type, (method_name, sort_alias_name) in alias_names.items():
282
+ if hf_api is None:
283
+ supported_params = sorted(COMMON_REPO_SEARCH_PARAMS)
284
+ sort_values = sorted(REPO_SORT_KEYS.get(repo_type, set()))
285
+ else:
286
+ method = getattr(hf_api.HfApi, method_name)
287
+ signature = inspect.signature(method)
288
+ supported_params = [
289
+ name for name in signature.parameters if name not in {"self", "token"}
290
+ ]
291
+ sort_alias = getattr(hf_api, sort_alias_name, None)
292
+ sort_values = _dedupe([str(value) for value in get_args(sort_alias)])
293
+ facts[repo_type] = {
294
+ "method_name": f"HfApi.{method_name}",
295
+ "supported_params": supported_params,
296
+ "sort_values": sort_values,
297
+ "expand_values": normalized_repo_expand_values(repo_type),
298
+ }
299
+ return facts
300
+
301
+
302
+ def _returns_contract(helper_name: str, row_type: str | None) -> dict[str, Any]:
303
+ metadata = HELPER_DEFAULT_METADATA.get(helper_name, {})
304
+ returns: dict[str, Any] = {"envelope": dict(HELPER_RESULT_ENVELOPE)}
305
+ if row_type is not None:
306
+ returns["row_type"] = row_type
307
+ for key in ("default_fields", "guaranteed_fields", "optional_fields"):
308
+ value = metadata.get(key)
309
+ if isinstance(value, list):
310
+ returns[key] = list(value)
311
+ return returns
312
+
313
+
314
+ def _limit_contract(helper_name: str) -> dict[str, Any] | None:
315
+ metadata = HELPER_DEFAULT_METADATA.get(helper_name, {})
316
+ limits: dict[str, Any] = {}
317
+ for key in ("default_limit", "max_limit"):
318
+ value = metadata.get(key)
319
+ if value is not None:
320
+ limits[key] = value
321
+ for key, value in PAGINATION_POLICY.get(helper_name, {}).items():
322
+ if value is not None and key not in limits:
323
+ limits[key] = value
324
+ return limits or None
325
+
326
+
327
+ def _fields_contract(field_group: str | None) -> dict[str, Any] | None:
328
+ if field_group is None:
329
+ return None
330
+ return {
331
+ "canonical_only": True,
332
+ "allowed_fields": list(FIELD_GROUPS[field_group]),
333
+ }
334
+
335
+
336
+ def _filter_contract(filter_param: str | None, field_group: str | None) -> tuple[str, dict[str, Any]] | None:
337
+ if filter_param is None or field_group is None:
338
+ return None
339
+ return (
340
+ f"{filter_param}_contract",
341
+ {
342
+ "allowed_fields": list(FIELD_GROUPS[field_group]),
343
+ "supported_ops": list(FILTER_OPERATORS),
344
+ "normalized_only": True,
345
+ },
346
+ )
347
+
348
+
349
+ def _notes_for_helper(helper_name: str) -> str | None:
350
+ note = HELPER_DEFAULT_METADATA.get(helper_name, {}).get("notes")
351
+ if not isinstance(note, str):
352
+ return None
353
+ cleaned = note.strip()
354
+ return cleaned or None
355
+
356
+
357
+ def _param_values_for_helper(helper_name: str) -> dict[str, list[str]] | None:
358
+ values = {
359
+ key: list(raw_values)
360
+ for key, raw_values in HELPER_CONTRACT_SPECS.get(helper_name, {})
361
+ .get("param_values", {})
362
+ .items()
363
+ }
364
+ if helper_name == "hf_repo_search":
365
+ values["sort"] = sorted(_dedupe([key for keys in REPO_SORT_KEYS.values() for key in keys]))
366
+ return values or None
367
+
368
+
369
+ def build_helper_contracts(
370
+ helper_functions: Mapping[str, Callable[..., Any]],
371
+ ) -> dict[str, HelperContract]:
372
+ upstream_facts = _upstream_repo_search_facts()
373
+ contracts: dict[str, HelperContract] = {}
374
+ for helper_name, fn in sorted(helper_functions.items()):
375
+ spec = HELPER_CONTRACT_SPECS.get(helper_name, {})
376
+ row_type = spec.get("row_type")
377
+ fields_group = spec.get("fields_group")
378
+ filter_param = spec.get("filter_param")
379
+ filter_group = spec.get("filter_group")
380
+ contract: HelperContract = {
381
+ "name": helper_name,
382
+ "signature": f"await {helper_name}{inspect.signature(fn)}",
383
+ "category": str(spec.get("category") or "helper"),
384
+ "supported_params": list(inspect.signature(fn).parameters),
385
+ "returns": _returns_contract(helper_name, row_type),
386
+ }
387
+ fields_contract = _fields_contract(fields_group)
388
+ if fields_contract is not None:
389
+ contract["fields_contract"] = fields_contract
390
+ filter_contract = _filter_contract(filter_param, filter_group)
391
+ if filter_contract is not None:
392
+ contract[filter_contract[0]] = filter_contract[1]
393
+ limit_contract = _limit_contract(helper_name)
394
+ if limit_contract is not None:
395
+ contract["limit_contract"] = limit_contract
396
+ param_values = _param_values_for_helper(helper_name)
397
+ if param_values is not None:
398
+ contract["param_values"] = param_values
399
+
400
+ upstream_repo_type = spec.get("upstream_repo_type")
401
+ if isinstance(upstream_repo_type, str):
402
+ upstream = upstream_facts[upstream_repo_type]
403
+ contract["backed_by"] = str(upstream["method_name"])
404
+ contract["sort_values"] = list(upstream["sort_values"])
405
+ contract["expand_values"] = list(upstream["expand_values"])
406
+ elif helper_name == "hf_repo_search":
407
+ contract["sort_values_by_repo_type"] = {
408
+ repo_type: sorted(values)
409
+ for repo_type, values in sorted(REPO_SORT_KEYS.items())
410
+ }
411
+
412
+ if helper_name == "hf_user_likes":
413
+ contract["sort_values"] = ["liked_at", "repo_likes", "repo_downloads"]
414
+
415
+ note = _notes_for_helper(helper_name)
416
+ if note is not None:
417
+ contract["notes"] = note
418
+ contracts[helper_name] = contract
419
+ return contracts
420
+
421
+
422
+ def _format_list(values: list[str] | None) -> str:
423
+ if not values:
424
+ return "[]"
425
+ return ", ".join(f"`{value}`" for value in values)
426
+
427
+
428
+ def _append_returns(lines: list[str], returns: Mapping[str, Any]) -> None:
429
+ lines.append("- returns:")
430
+ envelope = returns.get("envelope")
431
+ if isinstance(envelope, Mapping):
432
+ lines.append(" - envelope: `{ok, item, items, meta, error}`")
433
+ row_type = returns.get("row_type")
434
+ if isinstance(row_type, str):
435
+ lines.append(f" - row_type: `{row_type}`")
436
+ for key in ("default_fields", "guaranteed_fields", "optional_fields"):
437
+ value = returns.get(key)
438
+ if isinstance(value, list):
439
+ lines.append(f" - {key}: {_format_list(value)}")
440
+
441
+
442
+ def _append_named_contract(
443
+ lines: list[str],
444
+ label: str,
445
+ contract: Mapping[str, Any] | None,
446
+ ) -> None:
447
+ if not isinstance(contract, Mapping):
448
+ return
449
+ lines.append(f"- {label}:")
450
+ allowed_fields = contract.get("allowed_fields")
451
+ if isinstance(allowed_fields, list):
452
+ lines.append(f" - allowed_fields: {_format_list(allowed_fields)}")
453
+ supported_ops = contract.get("supported_ops")
454
+ if isinstance(supported_ops, list):
455
+ lines.append(f" - supported_ops: {_format_list(supported_ops)}")
456
+ canonical_only = contract.get("canonical_only")
457
+ if canonical_only is True:
458
+ lines.append(" - canonical_only: `true`")
459
+ normalized_only = contract.get("normalized_only")
460
+ if normalized_only is True:
461
+ lines.append(" - normalized_only: `true`")
462
+
463
+
464
+ def _append_limit_contract(lines: list[str], contract: Mapping[str, Any] | None) -> None:
465
+ if not isinstance(contract, Mapping) or not contract:
466
+ return
467
+ lines.append("- limit_contract:")
468
+ for key, value in contract.items():
469
+ lines.append(f" - {key}: `{value}`")
470
+
471
+
472
+ def _append_param_values(lines: list[str], param_values: Mapping[str, Any] | None) -> None:
473
+ if not isinstance(param_values, Mapping) or not param_values:
474
+ return
475
+ lines.append("- param_values:")
476
+ for key, value in param_values.items():
477
+ if isinstance(value, list):
478
+ lines.append(f" - {key}: {_format_list(value)}")
479
+
480
+
481
+ def build_helper_contracts_markdown(
482
+ helper_contracts: Mapping[str, Mapping[str, Any]],
483
+ ) -> str:
484
+ lines = [
485
+ "## Helper contracts (generated from runtime + wrapper metadata)",
486
+ "",
487
+ "These contracts describe the normalized wrapper surface exposed to generated code.",
488
+ "Field names and helper-visible enum values are canonical snake_case wrapper names.",
489
+ "",
490
+ "All helpers return the same envelope: `{ok, item, items, meta, error}`.",
491
+ "",
492
+ ]
493
+ for helper_name, contract in sorted(helper_contracts.items()):
494
+ lines.append(f"### {helper_name}")
495
+ lines.append("")
496
+ category = contract.get("category")
497
+ if isinstance(category, str):
498
+ lines.append(f"- category: `{category}`")
499
+ backed_by = contract.get("backed_by")
500
+ if isinstance(backed_by, str):
501
+ lines.append(f"- backed_by: `{backed_by}`")
502
+ returns = contract.get("returns")
503
+ if isinstance(returns, Mapping):
504
+ _append_returns(lines, returns)
505
+ supported_params = contract.get("supported_params")
506
+ if isinstance(supported_params, list):
507
+ lines.append(f"- supported_params: {_format_list(supported_params)}")
508
+ sort_values = contract.get("sort_values")
509
+ if isinstance(sort_values, list):
510
+ lines.append(f"- sort_values: {_format_list(sort_values)}")
511
+ sort_values_by_repo_type = contract.get("sort_values_by_repo_type")
512
+ if isinstance(sort_values_by_repo_type, Mapping):
513
+ lines.append("- sort_values_by_repo_type:")
514
+ for repo_type, values in sort_values_by_repo_type.items():
515
+ if isinstance(values, list):
516
+ lines.append(f" - {repo_type}: {_format_list(values)}")
517
+ expand_values = contract.get("expand_values")
518
+ if isinstance(expand_values, list):
519
+ lines.append(f"- expand_values: {_format_list(expand_values)}")
520
+ _append_param_values(lines, contract.get("param_values"))
521
+ _append_named_contract(lines, "fields_contract", contract.get("fields_contract"))
522
+ _append_named_contract(lines, "where_contract", contract.get("where_contract"))
523
+ _append_named_contract(
524
+ lines, "post_filter_contract", contract.get("post_filter_contract")
525
+ )
526
+ _append_limit_contract(lines, contract.get("limit_contract"))
527
+ notes = contract.get("notes")
528
+ if isinstance(notes, str):
529
+ lines.append(f"- notes: {notes}")
530
+ lines.append("")
531
+ return "\n".join(lines).rstrip() + "\n"
monty_api/helpers/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .activity import register_activity_helpers
2
+ from .collections import register_collection_helpers
3
+ from .introspection import register_introspection_helpers
4
+ from .profiles import register_profile_helpers
5
+ from .repos import register_repo_helpers
6
+
7
+ __all__ = [
8
+ "register_activity_helpers",
9
+ "register_collection_helpers",
10
+ "register_introspection_helpers",
11
+ "register_profile_helpers",
12
+ "register_repo_helpers",
13
+ ]
monty_api/helpers/activity.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917
4
+ from functools import partial
5
+ from typing import Any, Callable
6
+
7
+ from ..constants import (
8
+ ACTIVITY_CANONICAL_FIELDS,
9
+ EXHAUSTIVE_HELPER_RETURN_HARD_CAP,
10
+ RECENT_ACTIVITY_PAGE_SIZE,
11
+ RECENT_ACTIVITY_SCAN_MAX_PAGES,
12
+ )
13
+ from ..context_types import HelperRuntimeContext
14
+
15
+
16
+ async def hf_recent_activity(
17
+ ctx: HelperRuntimeContext,
18
+ feed_type: str | None = None,
19
+ entity: str | None = None,
20
+ activity_types: list[str] | None = None,
21
+ repo_types: list[str] | None = None,
22
+ limit: int | None = None,
23
+ max_pages: int | None = None,
24
+ start_cursor: str | None = None,
25
+ count_only: bool = False,
26
+ where: dict[str, Any] | None = None,
27
+ fields: list[str] | None = None,
28
+ ) -> dict[str, Any]:
29
+ start_calls = ctx.call_count["n"]
30
+ default_limit = ctx._policy_int("hf_recent_activity", "default_limit", 100)
31
+ page_cap = ctx._policy_int(
32
+ "hf_recent_activity", "page_limit", RECENT_ACTIVITY_PAGE_SIZE
33
+ )
34
+ pages_cap = ctx._policy_int(
35
+ "hf_recent_activity", "max_pages", RECENT_ACTIVITY_SCAN_MAX_PAGES
36
+ )
37
+ requested_max_pages = max_pages
38
+ ft = str(feed_type or "").strip().lower()
39
+ ent = str(entity or "").strip()
40
+ if ft not in {"user", "org"}:
41
+ if ft and (not ent):
42
+ ent = ft
43
+ ft = "user"
44
+ elif not ft and ent:
45
+ ft = "user"
46
+ if ft not in {"user", "org"}:
47
+ return ctx._helper_error(
48
+ start_calls=start_calls,
49
+ source="/api/recent-activity",
50
+ error="feed_type must be 'user' or 'org'",
51
+ )
52
+ if not ent:
53
+ return ctx._helper_error(
54
+ start_calls=start_calls,
55
+ source="/api/recent-activity",
56
+ error="entity is required",
57
+ )
58
+ limit_plan = ctx._resolve_exhaustive_limits(
59
+ limit=limit,
60
+ count_only=count_only,
61
+ default_limit=default_limit,
62
+ max_limit=EXHAUSTIVE_HELPER_RETURN_HARD_CAP,
63
+ )
64
+ applied_limit = int(limit_plan["applied_limit"])
65
+ page_lim = page_cap
66
+ pages_lim = ctx._clamp_int(
67
+ requested_max_pages, default=pages_cap, minimum=1, maximum=pages_cap
68
+ )
69
+ type_filter = {
70
+ str(t).strip().lower() for t in activity_types or [] if str(t).strip()
71
+ }
72
+ repo_filter = {
73
+ ctx._canonical_repo_type(t, default="")
74
+ for t in repo_types or []
75
+ if str(t).strip()
76
+ }
77
+ next_cursor = (
78
+ str(start_cursor).strip()
79
+ if isinstance(start_cursor, str) and start_cursor.strip()
80
+ else None
81
+ )
82
+ items: list[dict[str, Any]] = []
83
+ scanned = 0
84
+ matched = 0
85
+ pages = 0
86
+ exhausted_feed = False
87
+ stopped_for_budget = False
88
+ try:
89
+ normalized_where = ctx._normalize_where(
90
+ where, allowed_fields=ACTIVITY_CANONICAL_FIELDS
91
+ )
92
+ except ValueError as exc:
93
+ return ctx._helper_error(
94
+ start_calls=start_calls,
95
+ source="/api/recent-activity",
96
+ error=exc,
97
+ )
98
+ while pages < pages_lim and (applied_limit == 0 or len(items) < applied_limit):
99
+ if ctx._budget_remaining() <= 0:
100
+ stopped_for_budget = True
101
+ break
102
+ params: dict[str, Any] = {"feedType": ft, "entity": ent, "limit": page_lim}
103
+ if next_cursor:
104
+ params["cursor"] = next_cursor
105
+ resp = ctx._host_raw_call("/api/recent-activity", params=params)
106
+ if not resp.get("ok"):
107
+ if pages == 0:
108
+ return ctx._helper_error(
109
+ start_calls=start_calls,
110
+ source="/api/recent-activity",
111
+ error=resp.get("error") or "recent-activity fetch failed",
112
+ )
113
+ break
114
+ payload = resp.get("data") if isinstance(resp.get("data"), dict) else {}
115
+ rows = (
116
+ payload.get("recentActivity")
117
+ if isinstance(payload.get("recentActivity"), list)
118
+ else []
119
+ )
120
+ cursor_raw = payload.get("cursor")
121
+ next_cursor = cursor_raw if isinstance(cursor_raw, str) and cursor_raw else None
122
+ pages += 1
123
+ if not rows:
124
+ exhausted_feed = True
125
+ break
126
+ for row in rows:
127
+ if not isinstance(row, dict):
128
+ continue
129
+ scanned += 1
130
+ typ = str(row.get("type") or "").strip().lower()
131
+ repo_id = row.get("repoId")
132
+ repo_type = row.get("repoType")
133
+ repo_data = (
134
+ row.get("repoData") if isinstance(row.get("repoData"), dict) else None
135
+ )
136
+ repo_obj = row.get("repo") if isinstance(row.get("repo"), dict) else None
137
+ if repo_id is None and repo_data is not None:
138
+ repo_id = repo_data.get("id") or repo_data.get("name")
139
+ if repo_id is None and repo_obj is not None:
140
+ repo_id = repo_obj.get("id") or repo_obj.get("name")
141
+ if repo_type is None and repo_data is not None:
142
+ repo_type = repo_data.get("type")
143
+ if repo_type is None and repo_obj is not None:
144
+ repo_type = repo_obj.get("type")
145
+ rt = ctx._canonical_repo_type(repo_type, default="") if repo_type else ""
146
+ if type_filter and typ not in type_filter:
147
+ continue
148
+ if repo_filter and rt not in repo_filter:
149
+ continue
150
+ item = {
151
+ "timestamp": row.get("time"),
152
+ "event_type": row.get("type"),
153
+ "repo_type": rt or repo_type,
154
+ "repo_id": repo_id,
155
+ }
156
+ if not ctx._item_matches_where(item, normalized_where):
157
+ continue
158
+ matched += 1
159
+ if len(items) < applied_limit:
160
+ items.append(item)
161
+ if not next_cursor:
162
+ exhausted_feed = True
163
+ break
164
+ try:
165
+ items = ctx._project_activity_items(items, fields)
166
+ except ValueError as exc:
167
+ return ctx._helper_error(
168
+ start_calls=start_calls,
169
+ source="/api/recent-activity",
170
+ error=exc,
171
+ )
172
+ exact_count = exhausted_feed and (not stopped_for_budget)
173
+ sample_complete = (
174
+ exact_count and applied_limit >= matched and (not count_only or matched == 0)
175
+ )
176
+ page_limit_hit = (
177
+ next_cursor is not None and pages >= pages_lim and (not exhausted_feed)
178
+ )
179
+ more_available: bool | str = ctx._derive_more_available(
180
+ sample_complete=sample_complete,
181
+ exact_count=exact_count,
182
+ returned=len(items),
183
+ total=matched if exact_count else None,
184
+ )
185
+ if next_cursor is not None:
186
+ more_available = True
187
+ elif stopped_for_budget and (not exact_count):
188
+ more_available = "unknown"
189
+ meta = ctx._build_exhaustive_result_meta(
190
+ base_meta={
191
+ "scanned": scanned,
192
+ "total": matched,
193
+ "total_matched": matched,
194
+ "pages": pages,
195
+ "count_source": "scan" if exact_count else "none",
196
+ "lower_bound": not exact_count,
197
+ "page_limit": page_lim,
198
+ "stopped_for_budget": stopped_for_budget,
199
+ "feed_type": ft,
200
+ "entity": ent,
201
+ },
202
+ limit_plan=limit_plan,
203
+ matched_count=matched,
204
+ returned_count=len(items),
205
+ exact_count=exact_count,
206
+ count_only=count_only,
207
+ sample_complete=sample_complete,
208
+ more_available=more_available,
209
+ page_limit_hit=page_limit_hit,
210
+ truncated_extra=stopped_for_budget,
211
+ requested_max_pages=requested_max_pages,
212
+ applied_max_pages=pages_lim,
213
+ )
214
+ return ctx._helper_success(
215
+ start_calls=start_calls,
216
+ source="/api/recent-activity",
217
+ items=items,
218
+ meta=meta,
219
+ cursor=next_cursor,
220
+ )
221
+
222
+
223
+ def register_activity_helpers(
224
+ ctx: HelperRuntimeContext,
225
+ ) -> dict[str, Callable[..., Any]]:
226
+ return {"hf_recent_activity": partial(hf_recent_activity, ctx)}
monty_api/helpers/collections.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917
4
+ from functools import partial
5
+ from typing import Any, Callable
6
+
7
+ from ..constants import (
8
+ COLLECTION_CANONICAL_FIELDS,
9
+ OUTPUT_ITEMS_TRUNCATION_LIMIT,
10
+ REPO_CANONICAL_FIELDS,
11
+ )
12
+ from ..context_types import HelperRuntimeContext
13
+
14
+
15
+ async def hf_collections_search(
16
+ ctx: HelperRuntimeContext,
17
+ query: str | None = None,
18
+ owner: str | None = None,
19
+ limit: int = 20,
20
+ count_only: bool = False,
21
+ where: dict[str, Any] | None = None,
22
+ fields: list[str] | None = None,
23
+ ) -> dict[str, Any]:
24
+ start_calls = ctx.call_count["n"]
25
+ default_limit = ctx._policy_int("hf_collections_search", "default_limit", 20)
26
+ max_limit = ctx._policy_int(
27
+ "hf_collections_search", "max_limit", OUTPUT_ITEMS_TRUNCATION_LIMIT
28
+ )
29
+ if count_only:
30
+ limit = 0
31
+ applied_limit = ctx._clamp_int(
32
+ limit,
33
+ default=default_limit,
34
+ minimum=0,
35
+ maximum=max_limit,
36
+ )
37
+ owner_clean = str(owner or "").strip() or None
38
+ owner_casefold = owner_clean.casefold() if owner_clean is not None else None
39
+ fetch_limit = max_limit if applied_limit == 0 or owner_clean else applied_limit
40
+ if owner_clean:
41
+ fetch_limit = min(fetch_limit, 100)
42
+ term = str(query or "").strip()
43
+ if not term and owner_clean:
44
+ term = owner_clean
45
+ if not term:
46
+ return ctx._helper_error(
47
+ start_calls=start_calls,
48
+ source="/api/collections",
49
+ error="query or owner is required",
50
+ )
51
+ params: dict[str, Any] = {"limit": fetch_limit}
52
+ if term:
53
+ params["q"] = term
54
+ if owner_clean:
55
+ params["owner"] = owner_clean
56
+ resp = ctx._host_raw_call("/api/collections", params=params)
57
+ if not resp.get("ok"):
58
+ return ctx._helper_error(
59
+ start_calls=start_calls,
60
+ source="/api/collections",
61
+ error=resp.get("error") or "collections fetch failed",
62
+ )
63
+ payload = resp.get("data") if isinstance(resp.get("data"), list) else []
64
+
65
+ def _row_owner_matches_owner(row: Any) -> bool:
66
+ if owner_casefold is None or not isinstance(row, dict):
67
+ return owner_casefold is None
68
+ row_owner = ctx._author_from_any(row.get("owner")) or ctx._author_from_any(
69
+ row.get("ownerData")
70
+ )
71
+ if (
72
+ not row_owner
73
+ and isinstance(row.get("slug"), str)
74
+ and "/" in str(row.get("slug"))
75
+ ):
76
+ row_owner = str(row.get("slug")).split("/", 1)[0]
77
+ if not isinstance(row_owner, str) or not row_owner:
78
+ return False
79
+ return row_owner.casefold() == owner_casefold
80
+
81
+ owner_fallback_used = False
82
+ if owner_casefold is not None and not any(
83
+ _row_owner_matches_owner(row) for row in payload
84
+ ):
85
+ fallback_params: dict[str, Any] = {"limit": fetch_limit}
86
+ if term:
87
+ fallback_params["q"] = term
88
+ fallback_resp = ctx._host_raw_call("/api/collections", params=fallback_params)
89
+ if fallback_resp.get("ok"):
90
+ fallback_payload = (
91
+ fallback_resp.get("data")
92
+ if isinstance(fallback_resp.get("data"), list)
93
+ else []
94
+ )
95
+ if any(_row_owner_matches_owner(row) for row in fallback_payload):
96
+ payload = fallback_payload
97
+ owner_fallback_used = True
98
+
99
+ items: list[dict[str, Any]] = []
100
+ for row in payload[:fetch_limit]:
101
+ if not isinstance(row, dict):
102
+ continue
103
+ row_owner = ctx._author_from_any(row.get("owner")) or ctx._author_from_any(
104
+ row.get("ownerData")
105
+ )
106
+ if (
107
+ not row_owner
108
+ and isinstance(row.get("slug"), str)
109
+ and "/" in str(row.get("slug"))
110
+ ):
111
+ row_owner = str(row.get("slug")).split("/", 1)[0]
112
+ if owner_casefold is not None and (
113
+ not isinstance(row_owner, str) or row_owner.casefold() != owner_casefold
114
+ ):
115
+ continue
116
+ owner_payload = row.get("owner") if isinstance(row.get("owner"), dict) else {}
117
+ collection_items = (
118
+ row.get("items") if isinstance(row.get("items"), list) else []
119
+ )
120
+ slug = row.get("slug")
121
+ items.append(
122
+ {
123
+ "collection_id": slug,
124
+ "slug": slug,
125
+ "title": row.get("title"),
126
+ "owner": row_owner,
127
+ "owner_type": owner_payload.get("type")
128
+ if isinstance(owner_payload.get("type"), str)
129
+ else None,
130
+ "description": row.get("description"),
131
+ "gating": row.get("gating"),
132
+ "last_updated": row.get("lastUpdated"),
133
+ "item_count": len(collection_items),
134
+ }
135
+ )
136
+ try:
137
+ items = ctx._apply_where(
138
+ items, where, allowed_fields=COLLECTION_CANONICAL_FIELDS
139
+ )
140
+ except ValueError as exc:
141
+ return ctx._helper_error(
142
+ start_calls=start_calls,
143
+ source="/api/collections",
144
+ error=exc,
145
+ )
146
+ total_matched = len(items)
147
+ items = items[:applied_limit]
148
+ try:
149
+ items = ctx._project_collection_items(items, fields)
150
+ except ValueError as exc:
151
+ return ctx._helper_error(
152
+ start_calls=start_calls,
153
+ source="/api/collections",
154
+ error=exc,
155
+ )
156
+ truncated = (
157
+ applied_limit > 0 and total_matched > applied_limit
158
+ or (applied_limit == 0 and len(payload) >= fetch_limit)
159
+ )
160
+ return ctx._helper_success(
161
+ start_calls=start_calls,
162
+ source="/api/collections",
163
+ items=items,
164
+ scanned=len(payload),
165
+ matched=total_matched,
166
+ returned=len(items),
167
+ total=len(payload),
168
+ total_matched=total_matched,
169
+ total_population=len(payload),
170
+ truncated=truncated,
171
+ complete=not truncated,
172
+ query=term,
173
+ owner=owner_clean,
174
+ owner_case_insensitive_fallback=owner_fallback_used,
175
+ )
176
+
177
+
178
+ async def hf_collection_items(
179
+ ctx: HelperRuntimeContext,
180
+ collection_id: str,
181
+ repo_types: list[str] | None = None,
182
+ limit: int = 100,
183
+ count_only: bool = False,
184
+ where: dict[str, Any] | None = None,
185
+ fields: list[str] | None = None,
186
+ ) -> dict[str, Any]:
187
+ start_calls = ctx.call_count["n"]
188
+ default_limit = ctx._policy_int("hf_collection_items", "default_limit", 100)
189
+ max_limit = ctx._policy_int(
190
+ "hf_collection_items", "max_limit", OUTPUT_ITEMS_TRUNCATION_LIMIT
191
+ )
192
+ cid = str(collection_id or "").strip()
193
+ if not cid:
194
+ return ctx._helper_error(
195
+ start_calls=start_calls,
196
+ source="/api/collections/<collection_id>",
197
+ error="collection_id is required",
198
+ )
199
+ if count_only:
200
+ limit = 0
201
+ applied_limit = ctx._clamp_int(
202
+ limit,
203
+ default=default_limit,
204
+ minimum=0,
205
+ maximum=max_limit,
206
+ )
207
+ allowed_repo_types: set[str] | None = None
208
+ try:
209
+ raw_repo_types = (
210
+ ctx._coerce_str_list(repo_types) if repo_types is not None else []
211
+ )
212
+ except ValueError as exc:
213
+ return ctx._helper_error(
214
+ start_calls=start_calls,
215
+ source=f"/api/collections/{cid}",
216
+ error=exc,
217
+ collection_id=cid,
218
+ )
219
+ if raw_repo_types:
220
+ allowed_repo_types = set()
221
+ for raw in raw_repo_types:
222
+ canonical = ctx._canonical_repo_type(raw, default="")
223
+ if canonical not in {"model", "dataset", "space"}:
224
+ return ctx._helper_error(
225
+ start_calls=start_calls,
226
+ source=f"/api/collections/{cid}",
227
+ error=f"Unsupported repo_type '{raw}'",
228
+ collection_id=cid,
229
+ )
230
+ allowed_repo_types.add(canonical)
231
+ endpoint = f"/api/collections/{cid}"
232
+ resp = ctx._host_raw_call(endpoint)
233
+ if not resp.get("ok"):
234
+ return ctx._helper_error(
235
+ start_calls=start_calls,
236
+ source=endpoint,
237
+ error=resp.get("error") or "collection fetch failed",
238
+ collection_id=cid,
239
+ )
240
+ payload = resp.get("data") if isinstance(resp.get("data"), dict) else {}
241
+ raw_items = payload.get("items") if isinstance(payload.get("items"), list) else []
242
+ owner = ctx._author_from_any(payload.get("owner"))
243
+ owner_payload = (
244
+ payload.get("owner") if isinstance(payload.get("owner"), dict) else {}
245
+ )
246
+ if owner is None and "/" in cid:
247
+ owner = cid.split("/", 1)[0]
248
+ try:
249
+ normalized_where = ctx._normalize_where(
250
+ where, allowed_fields=REPO_CANONICAL_FIELDS
251
+ )
252
+ except ValueError as exc:
253
+ return ctx._helper_error(
254
+ start_calls=start_calls,
255
+ source=endpoint,
256
+ error=exc,
257
+ collection_id=cid,
258
+ )
259
+ normalized: list[dict[str, Any]] = []
260
+ for row in raw_items:
261
+ if not isinstance(row, dict):
262
+ continue
263
+ item = ctx._normalize_collection_repo_item(row)
264
+ if item is None:
265
+ continue
266
+ repo_type = item.get("repo_type")
267
+ if allowed_repo_types is not None and repo_type not in allowed_repo_types:
268
+ continue
269
+ if not ctx._item_matches_where(item, normalized_where):
270
+ continue
271
+ normalized.append(item)
272
+ total_matched = len(normalized)
273
+ items = [] if count_only else normalized[:applied_limit]
274
+ try:
275
+ items = ctx._project_repo_items(items, fields)
276
+ except ValueError as exc:
277
+ return ctx._helper_error(
278
+ start_calls=start_calls,
279
+ source=endpoint,
280
+ error=exc,
281
+ collection_id=cid,
282
+ )
283
+ truncated = applied_limit > 0 and total_matched > applied_limit
284
+ return ctx._helper_success(
285
+ start_calls=start_calls,
286
+ source=endpoint,
287
+ items=items,
288
+ scanned=len(raw_items),
289
+ matched=total_matched,
290
+ returned=len(items),
291
+ total=len(raw_items),
292
+ total_matched=total_matched,
293
+ total_population=len(raw_items),
294
+ truncated=truncated,
295
+ complete=not truncated,
296
+ collection_id=cid,
297
+ title=payload.get("title"),
298
+ owner=owner,
299
+ owner_type=owner_payload.get("type")
300
+ if isinstance(owner_payload.get("type"), str)
301
+ else None,
302
+ repo_types=sorted(allowed_repo_types)
303
+ if allowed_repo_types is not None
304
+ else None,
305
+ )
306
+
307
+
308
+ def register_collection_helpers(
309
+ ctx: HelperRuntimeContext,
310
+ ) -> dict[str, Callable[..., Any]]:
311
+ return {
312
+ "hf_collections_search": partial(hf_collections_search, ctx),
313
+ "hf_collection_items": partial(hf_collection_items, ctx),
314
+ }
monty_api/helpers/common.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ from ..context_types import HelperRuntimeContext
5
+
6
+
7
+ async def resolve_username_or_current(
8
+ ctx: HelperRuntimeContext,
9
+ username: str | None,
10
+ ) -> tuple[str | None, str | None]:
11
+ resolved = str(username or "").strip()
12
+ if resolved:
13
+ return resolved, None
14
+
15
+ whoami = await ctx.call_helper("hf_whoami")
16
+ if whoami.get("ok") is not True:
17
+ return (
18
+ None,
19
+ str(whoami.get("error") or "Could not resolve current authenticated user"),
20
+ )
21
+ item = ctx._helper_item(whoami)
22
+ current = item.get("username") if isinstance(item, dict) else None
23
+ if not isinstance(current, str) or not current.strip():
24
+ return (
25
+ None,
26
+ "username was not provided and current authenticated user could not be resolved",
27
+ )
28
+ return current.strip(), None
monty_api/helpers/introspection.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917
4
+ import inspect
5
+ from functools import partial
6
+ from typing import Any, Callable
7
+
8
+ from ..helper_contracts import build_helper_contracts
9
+ from ..constants import (
10
+ ACTIVITY_CANONICAL_FIELDS,
11
+ ACTOR_CANONICAL_FIELDS,
12
+ COLLECTION_CANONICAL_FIELDS,
13
+ DAILY_PAPER_CANONICAL_FIELDS,
14
+ DISCUSSION_CANONICAL_FIELDS,
15
+ DISCUSSION_DETAIL_CANONICAL_FIELDS,
16
+ DEFAULT_MAX_CALLS,
17
+ DEFAULT_TIMEOUT_SEC,
18
+ GRAPH_SCAN_LIMIT_CAP,
19
+ LIKES_SCAN_LIMIT_CAP,
20
+ MAX_CALLS_LIMIT,
21
+ OUTPUT_ITEMS_TRUNCATION_LIMIT,
22
+ PROFILE_CANONICAL_FIELDS,
23
+ RECENT_ACTIVITY_SCAN_MAX_PAGES,
24
+ REPO_CANONICAL_FIELDS,
25
+ TRENDING_ENDPOINT_MAX_LIMIT,
26
+ USER_CANONICAL_FIELDS,
27
+ USER_LIKES_CANONICAL_FIELDS,
28
+ )
29
+ from ..context_types import HelperRuntimeContext
30
+ from ..registry import (
31
+ HELPER_COVERED_ENDPOINT_PATTERNS,
32
+ HELPER_DEFAULT_METADATA,
33
+ PAGINATION_POLICY,
34
+ )
35
+
36
+
37
+ def _render_annotation(annotation: Any) -> str:
38
+ if annotation is inspect.Signature.empty:
39
+ return "Any"
40
+ return str(annotation)
41
+
42
+
43
+ def _render_default(default: Any) -> str | None:
44
+ if default is inspect.Signature.empty:
45
+ return None
46
+ return repr(default)
47
+
48
+
49
+ def _signature_payload(fn: Callable[..., Any]) -> dict[str, Any]:
50
+ signature = inspect.signature(fn)
51
+ parameters: list[dict[str, Any]] = []
52
+ for parameter in signature.parameters.values():
53
+ item: dict[str, Any] = {
54
+ "name": parameter.name,
55
+ "kind": str(parameter.kind).replace("Parameter.", "").lower(),
56
+ "annotation": _render_annotation(parameter.annotation),
57
+ "required": parameter.default is inspect.Signature.empty,
58
+ }
59
+ default = _render_default(parameter.default)
60
+ if default is not None:
61
+ item["default"] = default
62
+ parameters.append(item)
63
+ return {
64
+ "parameters": parameters,
65
+ "returns": _render_annotation(signature.return_annotation),
66
+ }
67
+
68
+
69
+ async def hf_runtime_capabilities(
70
+ ctx: HelperRuntimeContext,
71
+ section: str | None = None,
72
+ ) -> dict[str, Any]:
73
+ start_calls = ctx.call_count["n"]
74
+ ctx.internal_helper_used["used"] = True
75
+
76
+ helper_functions = {
77
+ **ctx.helper_registry,
78
+ "hf_runtime_capabilities": partial(hf_runtime_capabilities, ctx),
79
+ }
80
+ helper_payload = {
81
+ name: _signature_payload(fn) for name, fn in sorted(helper_functions.items())
82
+ }
83
+ helper_contracts = build_helper_contracts(helper_functions)
84
+ repo_type_helper_names = {
85
+ "dataset": "hf_datasets_search",
86
+ "model": "hf_models_search",
87
+ "space": "hf_spaces_search",
88
+ }
89
+
90
+ def _helper_contract(name: str) -> dict[str, Any]:
91
+ contract = helper_contracts.get(name)
92
+ return dict(contract) if isinstance(contract, dict) else {}
93
+
94
+ def _type_specific_params(name: str) -> list[str]:
95
+ params = _helper_contract(name).get("supported_params")
96
+ if not isinstance(params, list):
97
+ return []
98
+ common = {
99
+ "search",
100
+ "filter",
101
+ "author",
102
+ "sort",
103
+ "limit",
104
+ "expand",
105
+ "full",
106
+ "fields",
107
+ "post_filter",
108
+ }
109
+ return [param for param in params if param not in common]
110
+
111
+ manifest: dict[str, Any] = {
112
+ "overview": {
113
+ "helper_count": len(helper_functions),
114
+ "supports_current_user": True,
115
+ "helper_result_envelope": {
116
+ "ok": "bool",
117
+ "item": "dict | None",
118
+ "items": "list[dict]",
119
+ "meta": "dict",
120
+ "error": "str | None",
121
+ },
122
+ "raw_result_envelope": {
123
+ "result": "Any",
124
+ "meta": {
125
+ "ok": "bool",
126
+ "api_calls": "int",
127
+ "elapsed_ms": "int",
128
+ "limits_reached": "bool",
129
+ "limit_summary": "list[dict]",
130
+ },
131
+ },
132
+ },
133
+ "helpers": helper_payload,
134
+ "helper_contracts": helper_contracts,
135
+ "fields": {
136
+ "profile": list(PROFILE_CANONICAL_FIELDS),
137
+ "repo": list(REPO_CANONICAL_FIELDS),
138
+ "user": list(USER_CANONICAL_FIELDS),
139
+ "actor": list(ACTOR_CANONICAL_FIELDS),
140
+ "user_likes": list(USER_LIKES_CANONICAL_FIELDS),
141
+ "activity": list(ACTIVITY_CANONICAL_FIELDS),
142
+ "collection": list(COLLECTION_CANONICAL_FIELDS),
143
+ "daily_paper": list(DAILY_PAPER_CANONICAL_FIELDS),
144
+ "discussion": list(DISCUSSION_CANONICAL_FIELDS),
145
+ "discussion_detail": list(DISCUSSION_DETAIL_CANONICAL_FIELDS),
146
+ },
147
+ "helper_defaults": {
148
+ helper_name: dict(sorted(metadata.items()))
149
+ for helper_name, metadata in sorted(HELPER_DEFAULT_METADATA.items())
150
+ },
151
+ "limits": {
152
+ "default_timeout_sec": DEFAULT_TIMEOUT_SEC,
153
+ "default_max_calls": DEFAULT_MAX_CALLS,
154
+ "max_calls_limit": MAX_CALLS_LIMIT,
155
+ "output_items_truncation_limit": OUTPUT_ITEMS_TRUNCATION_LIMIT,
156
+ "graph_scan_limit_cap": GRAPH_SCAN_LIMIT_CAP,
157
+ "likes_scan_limit_cap": LIKES_SCAN_LIMIT_CAP,
158
+ "recent_activity_scan_max_pages": RECENT_ACTIVITY_SCAN_MAX_PAGES,
159
+ "trending_endpoint_max_limit": TRENDING_ENDPOINT_MAX_LIMIT,
160
+ "pagination_policy": {
161
+ helper_name: dict(sorted(policy.items()))
162
+ for helper_name, policy in sorted(PAGINATION_POLICY.items())
163
+ },
164
+ "helper_covered_endpoint_patterns": [
165
+ {"pattern": pattern, "helper": helper_name}
166
+ for pattern, helper_name in HELPER_COVERED_ENDPOINT_PATTERNS
167
+ ],
168
+ },
169
+ "repo_search": {
170
+ "helper_selection": {
171
+ "preferred_rule": (
172
+ "Prefer hf_models_search for model queries, hf_datasets_search for "
173
+ "dataset queries, and hf_spaces_search for space queries. Use "
174
+ "hf_repo_search only for intentionally cross-type search."
175
+ ),
176
+ "model": "hf_models_search",
177
+ "dataset": "hf_datasets_search",
178
+ "space": "hf_spaces_search",
179
+ "cross_type": "hf_repo_search",
180
+ },
181
+ "can_do": [
182
+ "search models",
183
+ "search datasets",
184
+ "search spaces",
185
+ "search across multiple repo types",
186
+ "project selected fields",
187
+ "apply local post-fetch row filtering",
188
+ ],
189
+ "parameter_contract": {
190
+ "search": {
191
+ "meaning": "Upstream Hugging Face search text.",
192
+ },
193
+ "filter": {
194
+ "meaning": (
195
+ "Upstream Hugging Face filter/tag argument passed directly into "
196
+ "the Hub client."
197
+ ),
198
+ },
199
+ "post_filter": {
200
+ "meaning": (
201
+ "Local predicate applied after the rows are fetched and normalized."
202
+ ),
203
+ "recommended_shapes": [
204
+ {"runtime_stage": "RUNNING"},
205
+ {"runtime_stage": {"in": ["BUILD_ERROR", "RUNTIME_ERROR"]}},
206
+ {"downloads": {"gte": 1000}},
207
+ {"likes": {"lte": 5000}},
208
+ ],
209
+ "prefer_for": [
210
+ "normalized returned fields such as runtime_stage",
211
+ "downloads / likes thresholds after a broad search",
212
+ ],
213
+ "avoid_when": [
214
+ "author is already a first-class helper argument",
215
+ "pipeline_tag is already a first-class model-search argument",
216
+ "dataset_name, language, task_ids, apps, models, or datasets already have first-class helper args",
217
+ ],
218
+ },
219
+ "fields": {
220
+ "meaning": "Select which normalized row fields are returned to the caller.",
221
+ "canonical_only": True,
222
+ },
223
+ },
224
+ "repo_type_specific_helpers": {
225
+ repo_type: {
226
+ "helper": helper_name,
227
+ "supported_params": _helper_contract(helper_name).get(
228
+ "supported_params"
229
+ ),
230
+ "type_specific_params": _type_specific_params(helper_name),
231
+ "sort_values": _helper_contract(helper_name).get("sort_values"),
232
+ "expand_values": _helper_contract(helper_name).get("expand_values"),
233
+ "fields_contract": _helper_contract(helper_name).get(
234
+ "fields_contract"
235
+ ),
236
+ "post_filter_contract": _helper_contract(helper_name).get(
237
+ "post_filter_contract"
238
+ ),
239
+ }
240
+ for repo_type, helper_name in sorted(repo_type_helper_names.items())
241
+ },
242
+ "generic_helper": {
243
+ "helper": "hf_repo_search",
244
+ "use_for": "Intentionally cross-type search only.",
245
+ "supports": _helper_contract("hf_repo_search").get("supported_params"),
246
+ "sort_values_by_repo_type": _helper_contract("hf_repo_search").get(
247
+ "sort_values_by_repo_type"
248
+ ),
249
+ "fields_contract": _helper_contract("hf_repo_search").get(
250
+ "fields_contract"
251
+ ),
252
+ "post_filter_contract": _helper_contract("hf_repo_search").get(
253
+ "post_filter_contract"
254
+ ),
255
+ "does_not_support": [
256
+ "repo-type-specific knobs such as pipeline_tag or dataset_name",
257
+ "nested advanced routing",
258
+ ],
259
+ },
260
+ "space_runtime_contract": {
261
+ "returned_field": "runtime_stage",
262
+ "full_runtime_field": "runtime",
263
+ "preferred_filter_channel": "post_filter",
264
+ "note": (
265
+ "Treat runtime_stage like any other returned field: use exact values "
266
+ "or an 'in' list in post_filter."
267
+ ),
268
+ "common_values": ["BUILD_ERROR", "RUNTIME_ERROR", "RUNNING", "SLEEPING"],
269
+ },
270
+ },
271
+ }
272
+ allowed_sections = sorted(manifest)
273
+ requested = str(section or "").strip().lower()
274
+ if requested:
275
+ if requested not in manifest:
276
+ return ctx._helper_error(
277
+ start_calls=start_calls,
278
+ source="internal://runtime-capabilities",
279
+ error=f"Unsupported section {section!r}. Allowed sections: {allowed_sections}",
280
+ section=section,
281
+ allowed_sections=allowed_sections,
282
+ )
283
+ payload = {
284
+ "section": requested,
285
+ "content": manifest[requested],
286
+ "allowed_sections": allowed_sections,
287
+ }
288
+ else:
289
+ payload = {"allowed_sections": allowed_sections, **manifest}
290
+ return ctx._helper_success(
291
+ start_calls=start_calls,
292
+ source="internal://runtime-capabilities",
293
+ items=[payload],
294
+ section=requested or None,
295
+ )
296
+
297
+
298
+ def register_introspection_helpers(
299
+ ctx: HelperRuntimeContext,
300
+ ) -> dict[str, Callable[..., Any]]:
301
+ return {"hf_runtime_capabilities": partial(hf_runtime_capabilities, ctx)}
monty_api/helpers/profiles.py ADDED
@@ -0,0 +1,861 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917
4
+ from itertools import islice
5
+ import re
6
+ from typing import Any, Callable
7
+ from ..context_types import HelperRuntimeContext
8
+ from ..constants import (
9
+ ACTOR_CANONICAL_FIELDS,
10
+ EXHAUSTIVE_HELPER_RETURN_HARD_CAP,
11
+ GRAPH_SCAN_LIMIT_CAP,
12
+ OUTPUT_ITEMS_TRUNCATION_LIMIT,
13
+ USER_SUMMARY_ACTIVITY_MAX_PAGES,
14
+ USER_SUMMARY_LIKES_SCAN_LIMIT,
15
+ )
16
+
17
+
18
+ from .common import resolve_username_or_current
19
+
20
+ from functools import partial
21
+
22
+
23
+ def _clean_social_handle(value: Any) -> str | None:
24
+ if not isinstance(value, str):
25
+ return None
26
+ cleaned = value.strip()
27
+ if not cleaned:
28
+ return None
29
+ if re.match("^https?://", cleaned, flags=re.IGNORECASE):
30
+ return cleaned
31
+ return cleaned.lstrip("@")
32
+
33
+
34
+ def _social_url(kind: str, value: Any) -> str | None:
35
+ cleaned = _clean_social_handle(value)
36
+ if cleaned is None:
37
+ return None
38
+ if re.match("^https?://", cleaned, flags=re.IGNORECASE):
39
+ return cleaned
40
+ if kind == "twitter":
41
+ return f"https://twitter.com/{cleaned}"
42
+ if kind == "github":
43
+ return f"https://github.com/{cleaned}"
44
+ if kind == "linkedin":
45
+ if cleaned.startswith(("in/", "company/")):
46
+ return f"https://www.linkedin.com/{cleaned}"
47
+ return f"https://www.linkedin.com/in/{cleaned}"
48
+ if kind == "bluesky":
49
+ return f"https://bsky.app/profile/{cleaned}"
50
+ return cleaned
51
+
52
+
53
+ async def hf_whoami(ctx: HelperRuntimeContext) -> dict[str, Any]:
54
+ start_calls = ctx.call_count["n"]
55
+ endpoint = "/api/whoami-v2"
56
+ token = ctx._load_token()
57
+ if token is None:
58
+ return ctx._helper_error(
59
+ start_calls=start_calls,
60
+ source=endpoint,
61
+ error="Current authenticated user is unavailable for this request. No request-scoped or fallback HF token was found.",
62
+ )
63
+ try:
64
+ payload = ctx._host_hf_call(
65
+ endpoint,
66
+ lambda: ctx._get_hf_api_client().whoami(token=token, cache=True),
67
+ )
68
+ except Exception as e:
69
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=e)
70
+ username = payload.get("name") or payload.get("user") or payload.get("username")
71
+ item = {
72
+ "username": username,
73
+ "fullname": payload.get("fullname"),
74
+ "is_pro": payload.get("isPro"),
75
+ }
76
+ items = [item] if isinstance(username, str) and username else []
77
+ return ctx._helper_success(
78
+ start_calls=start_calls,
79
+ source=endpoint,
80
+ items=items,
81
+ scanned=1,
82
+ matched=len(items),
83
+ returned=len(items),
84
+ truncated=False,
85
+ )
86
+
87
+
88
+ async def _hf_user_overview(ctx: HelperRuntimeContext, username: str) -> dict[str, Any]:
89
+ start_calls = ctx.call_count["n"]
90
+ u = str(username or "").strip()
91
+ if not u:
92
+ return ctx._helper_error(
93
+ start_calls=start_calls,
94
+ source="/api/users/<u>/overview",
95
+ error="username is required",
96
+ )
97
+ endpoint = f"/api/users/{u}/overview"
98
+ try:
99
+ obj = ctx._host_hf_call(
100
+ endpoint, lambda: ctx._get_hf_api_client().get_user_overview(u)
101
+ )
102
+ except Exception as e:
103
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=e)
104
+ twitter = getattr(obj, "twitter", None) or getattr(obj, "twitterUsername", None)
105
+ github = getattr(obj, "github", None) or getattr(obj, "githubUsername", None)
106
+ linkedin = getattr(obj, "linkedin", None) or getattr(obj, "linkedinUsername", None)
107
+ bluesky = getattr(obj, "bluesky", None) or getattr(obj, "blueskyUsername", None)
108
+ if ctx._budget_remaining() > 0 and any(
109
+ (v in {None, ""} for v in [twitter, github, linkedin, bluesky])
110
+ ):
111
+ socials_ep = f"/api/users/{u}/socials"
112
+ socials_resp = ctx._host_raw_call(socials_ep)
113
+ if socials_resp.get("ok"):
114
+ socials_payload = (
115
+ socials_resp.get("data")
116
+ if isinstance(socials_resp.get("data"), dict)
117
+ else {}
118
+ )
119
+ handles = (
120
+ socials_payload.get("socialHandles")
121
+ if isinstance(socials_payload.get("socialHandles"), dict)
122
+ else {}
123
+ )
124
+ twitter = twitter or handles.get("twitter")
125
+ github = github or handles.get("github")
126
+ linkedin = linkedin or handles.get("linkedin")
127
+ bluesky = bluesky or handles.get("bluesky")
128
+ orgs_raw = getattr(obj, "orgs", None)
129
+ org_names: list[str] | None = None
130
+ if isinstance(orgs_raw, (list, tuple, set)):
131
+ names = []
132
+ for org in orgs_raw:
133
+ if isinstance(org, str) and org.strip():
134
+ names.append(org.strip())
135
+ continue
136
+ name = getattr(org, "name", None)
137
+ if isinstance(name, str) and name.strip():
138
+ names.append(name.strip())
139
+ org_names = names or None
140
+ twitter_handle = _clean_social_handle(twitter)
141
+ github_handle = _clean_social_handle(github)
142
+ linkedin_handle = _clean_social_handle(linkedin)
143
+ bluesky_handle = _clean_social_handle(bluesky)
144
+ item = {
145
+ "username": obj.username or u,
146
+ "fullname": obj.fullname,
147
+ "bio": getattr(obj, "details", None),
148
+ "avatar_url": obj.avatar_url,
149
+ "website_url": getattr(obj, "websiteUrl", None),
150
+ "twitter": _social_url("twitter", twitter_handle),
151
+ "github": _social_url("github", github_handle),
152
+ "linkedin": _social_url("linkedin", linkedin_handle),
153
+ "bluesky": _social_url("bluesky", bluesky_handle),
154
+ "twitter_handle": twitter_handle,
155
+ "github_handle": github_handle,
156
+ "linkedin_handle": linkedin_handle,
157
+ "bluesky_handle": bluesky_handle,
158
+ "followers": ctx._as_int(obj.num_followers),
159
+ "following": ctx._as_int(obj.num_following),
160
+ "likes": ctx._as_int(obj.num_likes),
161
+ "models": ctx._as_int(getattr(obj, "num_models", None)),
162
+ "datasets": ctx._as_int(getattr(obj, "num_datasets", None)),
163
+ "spaces": ctx._as_int(getattr(obj, "num_spaces", None)),
164
+ "discussions": ctx._as_int(getattr(obj, "num_discussions", None)),
165
+ "papers": ctx._as_int(getattr(obj, "num_papers", None)),
166
+ "upvotes": ctx._as_int(getattr(obj, "num_upvotes", None)),
167
+ "orgs": org_names,
168
+ "is_pro": obj.is_pro,
169
+ }
170
+ return ctx._helper_success(
171
+ start_calls=start_calls,
172
+ source=endpoint,
173
+ items=[item],
174
+ scanned=1,
175
+ matched=1,
176
+ returned=1,
177
+ truncated=False,
178
+ )
179
+
180
+
181
+ async def _hf_org_overview(
182
+ ctx: HelperRuntimeContext, organization: str
183
+ ) -> dict[str, Any]:
184
+ start_calls = ctx.call_count["n"]
185
+ org = str(organization or "").strip()
186
+ if not org:
187
+ return ctx._helper_error(
188
+ start_calls=start_calls,
189
+ source="/api/organizations/<o>/overview",
190
+ error="organization is required",
191
+ )
192
+ endpoint = f"/api/organizations/{org}/overview"
193
+ try:
194
+ obj = ctx._host_hf_call(
195
+ endpoint,
196
+ lambda: ctx._get_hf_api_client().get_organization_overview(org),
197
+ )
198
+ except Exception as e:
199
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=e)
200
+ item = {
201
+ "organization": obj.name or org,
202
+ "display_name": obj.fullname,
203
+ "avatar_url": obj.avatar_url,
204
+ "description": obj.details,
205
+ "website_url": getattr(obj, "websiteUrl", None),
206
+ "followers": ctx._as_int(obj.num_followers),
207
+ "members": ctx._as_int(obj.num_users),
208
+ "models": ctx._as_int(getattr(obj, "num_models", None)),
209
+ "datasets": ctx._as_int(getattr(obj, "num_datasets", None)),
210
+ "spaces": ctx._as_int(getattr(obj, "num_spaces", None)),
211
+ }
212
+ return ctx._helper_success(
213
+ start_calls=start_calls,
214
+ source=endpoint,
215
+ items=[item],
216
+ scanned=1,
217
+ matched=1,
218
+ returned=1,
219
+ truncated=False,
220
+ )
221
+
222
+
223
+ async def hf_org_members(
224
+ ctx: HelperRuntimeContext,
225
+ organization: str,
226
+ limit: int | None = None,
227
+ scan_limit: int | None = None,
228
+ count_only: bool = False,
229
+ where: dict[str, Any] | None = None,
230
+ fields: list[str] | None = None,
231
+ ) -> dict[str, Any]:
232
+ start_calls = ctx.call_count["n"]
233
+ org = str(organization or "").strip()
234
+ if not org:
235
+ return ctx._helper_error(
236
+ start_calls=start_calls,
237
+ source="/api/organizations/<o>/members",
238
+ error="organization is required",
239
+ )
240
+ default_limit = ctx._policy_int("hf_org_members", "default_limit", 100)
241
+ scan_cap = ctx._policy_int("hf_org_members", "scan_max", GRAPH_SCAN_LIMIT_CAP)
242
+ limit_plan = ctx._resolve_exhaustive_limits(
243
+ limit=limit,
244
+ count_only=count_only,
245
+ default_limit=default_limit,
246
+ max_limit=EXHAUSTIVE_HELPER_RETURN_HARD_CAP,
247
+ scan_limit=scan_limit,
248
+ scan_cap=scan_cap,
249
+ )
250
+ applied_limit = int(limit_plan["applied_limit"])
251
+ scan_lim = int(limit_plan["applied_scan_limit"])
252
+ has_where = isinstance(where, dict) and bool(where)
253
+ overview_total: int | None = None
254
+ overview_source = f"/api/organizations/{org}/overview"
255
+ if ctx._budget_remaining() > 0:
256
+ try:
257
+ org_obj = ctx._host_hf_call(
258
+ overview_source,
259
+ lambda: ctx._get_hf_api_client().get_organization_overview(org),
260
+ )
261
+ overview_total = ctx._as_int(getattr(org_obj, "num_users", None))
262
+ except Exception:
263
+ overview_total = None
264
+ if count_only and (not has_where) and (overview_total is not None):
265
+ return ctx._overview_count_only_success(
266
+ start_calls=start_calls,
267
+ source=overview_source,
268
+ total=overview_total,
269
+ limit_plan=limit_plan,
270
+ base_meta={
271
+ "scanned": 1,
272
+ "count_source": "overview",
273
+ "organization": org,
274
+ },
275
+ )
276
+ endpoint = f"/api/organizations/{org}/members"
277
+ try:
278
+ rows = ctx._host_hf_call(
279
+ endpoint,
280
+ lambda: list(
281
+ islice(
282
+ ctx._get_hf_api_client().list_organization_members(org),
283
+ scan_lim,
284
+ )
285
+ ),
286
+ )
287
+ except Exception as e:
288
+ return ctx._helper_error(
289
+ start_calls=start_calls, source=endpoint, error=e, organization=org
290
+ )
291
+ normalized: list[dict[str, Any]] = []
292
+ for row in rows:
293
+ handle = getattr(row, "username", None)
294
+ if not isinstance(handle, str) or not handle:
295
+ continue
296
+ item = {
297
+ "username": handle,
298
+ "fullname": getattr(row, "fullname", None),
299
+ "is_pro": getattr(row, "is_pro", None),
300
+ "role": getattr(row, "role", None),
301
+ }
302
+ normalized.append(item)
303
+ try:
304
+ normalized = ctx._apply_where(
305
+ normalized, where, allowed_fields=ACTOR_CANONICAL_FIELDS
306
+ )
307
+ except ValueError as exc:
308
+ return ctx._helper_error(
309
+ start_calls=start_calls,
310
+ source=endpoint,
311
+ error=exc,
312
+ organization=org,
313
+ )
314
+ observed_total = len(rows)
315
+ scan_exhaustive = observed_total < scan_lim
316
+ overview_list_mismatch = (
317
+ overview_total is not None
318
+ and scan_exhaustive
319
+ and (observed_total != overview_total)
320
+ )
321
+ if has_where:
322
+ exact_count = scan_exhaustive
323
+ total = len(normalized)
324
+ total_matched = len(normalized)
325
+ elif overview_total is not None:
326
+ exact_count = True
327
+ total = overview_total
328
+ total_matched = overview_total
329
+ else:
330
+ exact_count = scan_exhaustive
331
+ total = observed_total
332
+ total_matched = observed_total
333
+ total_available = overview_total if overview_total is not None else observed_total
334
+ items = normalized[:applied_limit]
335
+ scan_limit_hit = not exact_count and observed_total >= scan_lim
336
+ count_source = (
337
+ "overview" if overview_total is not None and (not has_where) else "scan"
338
+ )
339
+ sample_complete = (
340
+ exact_count
341
+ and len(normalized) <= applied_limit
342
+ and (not count_only or len(normalized) == 0)
343
+ )
344
+ more_available = ctx._derive_more_available(
345
+ sample_complete=sample_complete,
346
+ exact_count=exact_count,
347
+ returned=len(items),
348
+ total=total,
349
+ )
350
+ if not exact_count and scan_limit_hit:
351
+ more_available = "unknown" if has_where else True
352
+ try:
353
+ items = ctx._project_actor_items(items, fields)
354
+ except ValueError as exc:
355
+ return ctx._helper_error(
356
+ start_calls=start_calls,
357
+ source=endpoint,
358
+ error=exc,
359
+ organization=org,
360
+ )
361
+ meta = ctx._build_exhaustive_result_meta(
362
+ base_meta={
363
+ "scanned": observed_total,
364
+ "total": total,
365
+ "total_available": total_available,
366
+ "total_matched": total_matched,
367
+ "count_source": count_source,
368
+ "lower_bound": bool(has_where and (not exact_count)),
369
+ "overview_total": overview_total,
370
+ "listed_total": observed_total,
371
+ "overview_list_mismatch": overview_list_mismatch,
372
+ "organization": org,
373
+ },
374
+ limit_plan=limit_plan,
375
+ matched_count=len(normalized),
376
+ returned_count=len(items),
377
+ exact_count=exact_count,
378
+ count_only=count_only,
379
+ sample_complete=sample_complete,
380
+ more_available=more_available,
381
+ scan_limit_hit=scan_limit_hit,
382
+ )
383
+ return ctx._helper_success(
384
+ start_calls=start_calls, source=endpoint, items=items, meta=meta
385
+ )
386
+
387
+
388
+ async def _user_graph_helper(
389
+ ctx: HelperRuntimeContext,
390
+ kind: str,
391
+ username: str,
392
+ pro_only: bool | None,
393
+ limit: int | None,
394
+ scan_limit: int | None,
395
+ count_only: bool,
396
+ where: dict[str, Any] | None,
397
+ fields: list[str] | None,
398
+ *,
399
+ helper_name: str,
400
+ ) -> dict[str, Any]:
401
+ start_calls = ctx.call_count["n"]
402
+ default_limit = ctx._policy_int(helper_name, "default_limit", 100)
403
+ scan_cap = ctx._policy_int(helper_name, "scan_max", GRAPH_SCAN_LIMIT_CAP)
404
+ max_limit = ctx._policy_int(
405
+ helper_name, "max_limit", EXHAUSTIVE_HELPER_RETURN_HARD_CAP
406
+ )
407
+ u = str(username or "").strip()
408
+ if not u:
409
+ return ctx._helper_error(
410
+ start_calls=start_calls,
411
+ source=f"/api/users/<u>/{kind}",
412
+ error="username is required",
413
+ )
414
+ limit_plan = ctx._resolve_exhaustive_limits(
415
+ limit=limit,
416
+ count_only=count_only,
417
+ default_limit=default_limit,
418
+ max_limit=max_limit,
419
+ scan_limit=scan_limit,
420
+ scan_cap=scan_cap,
421
+ )
422
+ applied_limit = int(limit_plan["applied_limit"])
423
+ scan_lim = int(limit_plan["applied_scan_limit"])
424
+ has_where = isinstance(where, dict) and bool(where)
425
+ filtered = pro_only is not None or has_where
426
+ entity_type = "user"
427
+ overview_total: int | None = None
428
+ overview_source = f"/api/users/{u}/overview"
429
+ if ctx._budget_remaining() > 0:
430
+ try:
431
+ user_obj = ctx._host_hf_call(
432
+ overview_source,
433
+ lambda: ctx._get_hf_api_client().get_user_overview(u),
434
+ )
435
+ overview_total = ctx._as_int(
436
+ user_obj.num_followers
437
+ if kind == "followers"
438
+ else user_obj.num_following
439
+ )
440
+ except Exception:
441
+ org_overview_source = f"/api/organizations/{u}/overview"
442
+ try:
443
+ org_obj = ctx._host_hf_call(
444
+ org_overview_source,
445
+ lambda: ctx._get_hf_api_client().get_organization_overview(u),
446
+ )
447
+ except Exception:
448
+ overview_total = None
449
+ else:
450
+ entity_type = "organization"
451
+ overview_source = org_overview_source
452
+ if kind != "followers":
453
+ return ctx._helper_error(
454
+ start_calls=start_calls,
455
+ source=f"/api/organizations/{u}/{kind}",
456
+ error="organization graph only supports relation='followers'; organizations do not expose a following list",
457
+ relation=kind,
458
+ organization=u,
459
+ entity=u,
460
+ entity_type=entity_type,
461
+ )
462
+ overview_total = ctx._as_int(getattr(org_obj, "num_followers", None))
463
+ if count_only and (not filtered) and (overview_total is not None):
464
+ return ctx._overview_count_only_success(
465
+ start_calls=start_calls,
466
+ source=overview_source,
467
+ total=overview_total,
468
+ limit_plan=limit_plan,
469
+ base_meta={
470
+ "scanned": 1,
471
+ "count_source": "overview",
472
+ "relation": kind,
473
+ "pro_only": pro_only,
474
+ "where_applied": has_where,
475
+ "entity": u,
476
+ "entity_type": entity_type,
477
+ "username": u,
478
+ "organization": u if entity_type == "organization" else None,
479
+ },
480
+ )
481
+ endpoint = f"/api/users/{u}/{kind}"
482
+ try:
483
+ if entity_type == "organization":
484
+ endpoint = f"/api/organizations/{u}/followers"
485
+ rows = ctx._host_hf_call(
486
+ endpoint,
487
+ lambda: list(
488
+ islice(
489
+ ctx._get_hf_api_client().list_organization_followers(u),
490
+ scan_lim,
491
+ )
492
+ ),
493
+ )
494
+ elif kind == "followers":
495
+ rows = ctx._host_hf_call(
496
+ endpoint,
497
+ lambda: list(
498
+ islice(ctx._get_hf_api_client().list_user_followers(u), scan_lim)
499
+ ),
500
+ )
501
+ else:
502
+ rows = ctx._host_hf_call(
503
+ endpoint,
504
+ lambda: list(
505
+ islice(ctx._get_hf_api_client().list_user_following(u), scan_lim)
506
+ ),
507
+ )
508
+ except Exception as e:
509
+ return ctx._helper_error(
510
+ start_calls=start_calls,
511
+ source=endpoint,
512
+ error=e,
513
+ relation=kind,
514
+ username=u,
515
+ entity=u,
516
+ entity_type=entity_type,
517
+ organization=u if entity_type == "organization" else None,
518
+ )
519
+ normalized: list[dict[str, Any]] = []
520
+ for row in rows:
521
+ handle = getattr(row, "username", None)
522
+ if not isinstance(handle, str) or not handle:
523
+ continue
524
+ item = {
525
+ "username": handle,
526
+ "fullname": getattr(row, "fullname", None),
527
+ "is_pro": getattr(row, "is_pro", None),
528
+ }
529
+ if pro_only is True and item.get("is_pro") is not True:
530
+ continue
531
+ if pro_only is False and item.get("is_pro") is True:
532
+ continue
533
+ normalized.append(item)
534
+ try:
535
+ normalized = ctx._apply_where(
536
+ normalized, where, allowed_fields=ACTOR_CANONICAL_FIELDS
537
+ )
538
+ except ValueError as exc:
539
+ return ctx._helper_error(
540
+ start_calls=start_calls,
541
+ source=endpoint,
542
+ error=exc,
543
+ relation=kind,
544
+ username=u,
545
+ entity=u,
546
+ entity_type=entity_type,
547
+ organization=u if entity_type == "organization" else None,
548
+ )
549
+ observed_total = len(rows)
550
+ scan_exhaustive = observed_total < scan_lim
551
+ overview_list_mismatch = (
552
+ overview_total is not None
553
+ and scan_exhaustive
554
+ and (observed_total != overview_total)
555
+ )
556
+ if filtered:
557
+ exact_count = scan_exhaustive
558
+ total = len(normalized)
559
+ total_matched = len(normalized)
560
+ elif overview_total is not None:
561
+ exact_count = True
562
+ total = overview_total
563
+ total_matched = overview_total
564
+ else:
565
+ exact_count = scan_exhaustive
566
+ total = observed_total
567
+ total_matched = observed_total
568
+ total_available = overview_total if overview_total is not None else observed_total
569
+ items = normalized[:applied_limit]
570
+ scan_limit_hit = not exact_count and observed_total >= scan_lim
571
+ count_source = (
572
+ "overview" if overview_total is not None and (not filtered) else "scan"
573
+ )
574
+ sample_complete = (
575
+ exact_count
576
+ and len(normalized) <= applied_limit
577
+ and (not count_only or len(normalized) == 0)
578
+ )
579
+ more_available = ctx._derive_more_available(
580
+ sample_complete=sample_complete,
581
+ exact_count=exact_count,
582
+ returned=len(items),
583
+ total=total,
584
+ )
585
+ if not exact_count and scan_limit_hit:
586
+ more_available = "unknown" if filtered else True
587
+ try:
588
+ items = ctx._project_actor_items(items, fields)
589
+ except ValueError as exc:
590
+ return ctx._helper_error(
591
+ start_calls=start_calls,
592
+ source=endpoint,
593
+ error=exc,
594
+ relation=kind,
595
+ username=u,
596
+ entity=u,
597
+ entity_type=entity_type,
598
+ organization=u if entity_type == "organization" else None,
599
+ )
600
+ meta = ctx._build_exhaustive_result_meta(
601
+ base_meta={
602
+ "scanned": observed_total,
603
+ "total": total,
604
+ "total_available": total_available,
605
+ "total_matched": total_matched,
606
+ "count_source": count_source,
607
+ "lower_bound": bool(filtered and (not exact_count)),
608
+ "overview_total": overview_total,
609
+ "listed_total": observed_total,
610
+ "overview_list_mismatch": overview_list_mismatch,
611
+ "relation": kind,
612
+ "pro_only": pro_only,
613
+ "where_applied": has_where,
614
+ "entity": u,
615
+ "entity_type": entity_type,
616
+ "username": u,
617
+ "organization": u if entity_type == "organization" else None,
618
+ },
619
+ limit_plan=limit_plan,
620
+ matched_count=len(normalized),
621
+ returned_count=len(items),
622
+ exact_count=exact_count,
623
+ count_only=count_only,
624
+ sample_complete=sample_complete,
625
+ more_available=more_available,
626
+ scan_limit_hit=scan_limit_hit,
627
+ )
628
+ return ctx._helper_success(
629
+ start_calls=start_calls, source=endpoint, items=items, meta=meta
630
+ )
631
+
632
+
633
+ async def hf_profile_summary(
634
+ ctx: HelperRuntimeContext,
635
+ handle: str | None = None,
636
+ include: list[str] | None = None,
637
+ likes_limit: int = 10,
638
+ activity_limit: int = 10,
639
+ ) -> dict[str, Any]:
640
+ start_calls = ctx.call_count["n"]
641
+ resolved_handle, resolve_error = await resolve_username_or_current(ctx, handle)
642
+ if resolve_error:
643
+ return ctx._helper_error(
644
+ start_calls=start_calls,
645
+ source="/api/users/<u>/overview",
646
+ error=resolve_error,
647
+ )
648
+ if not isinstance(resolved_handle, str):
649
+ return ctx._helper_error(
650
+ start_calls=start_calls,
651
+ source="/api/users/<u>/overview",
652
+ error="handle was not provided and current authenticated user could not be resolved",
653
+ )
654
+ try:
655
+ requested_sections = (
656
+ {part.lower() for part in ctx._coerce_str_list(include) if part.strip()}
657
+ if include is not None
658
+ else set()
659
+ )
660
+ except ValueError as e:
661
+ return ctx._helper_error(
662
+ start_calls=start_calls,
663
+ source=f"/api/users/{resolved_handle}/overview",
664
+ error=e,
665
+ )
666
+ invalid_sections = sorted(requested_sections - {"likes", "activity"})
667
+ if invalid_sections:
668
+ return ctx._helper_error(
669
+ start_calls=start_calls,
670
+ source=f"/api/users/{resolved_handle}/overview",
671
+ error=f"Unsupported include values: {invalid_sections}",
672
+ )
673
+ likes_lim = ctx._clamp_int(
674
+ likes_limit, default=10, minimum=0, maximum=OUTPUT_ITEMS_TRUNCATION_LIMIT
675
+ )
676
+ activity_lim = ctx._clamp_int(
677
+ activity_limit, default=10, minimum=0, maximum=OUTPUT_ITEMS_TRUNCATION_LIMIT
678
+ )
679
+ section_errors: dict[str, str] = {}
680
+ user_overview = await _hf_user_overview(ctx, resolved_handle)
681
+ if user_overview.get("ok") is True:
682
+ overview_item = ctx._helper_item(user_overview) or {"username": resolved_handle}
683
+ item: dict[str, Any] = {
684
+ "handle": str(overview_item.get("username") or resolved_handle),
685
+ "entity_type": "user",
686
+ "display_name": overview_item.get("fullname")
687
+ or str(overview_item.get("username") or resolved_handle),
688
+ "bio": overview_item.get("bio"),
689
+ "avatar_url": overview_item.get("avatar_url"),
690
+ "website_url": overview_item.get("website_url"),
691
+ "twitter_url": overview_item.get("twitter"),
692
+ "github_url": overview_item.get("github"),
693
+ "linkedin_url": overview_item.get("linkedin"),
694
+ "bluesky_url": overview_item.get("bluesky"),
695
+ "followers_count": ctx._overview_count(overview_item, "followers"),
696
+ "following_count": ctx._overview_count(overview_item, "following"),
697
+ "likes_count": ctx._overview_count(overview_item, "likes"),
698
+ "models_count": ctx._overview_count(overview_item, "models"),
699
+ "datasets_count": ctx._overview_count(overview_item, "datasets"),
700
+ "spaces_count": ctx._overview_count(overview_item, "spaces"),
701
+ "discussions_count": ctx._overview_count(overview_item, "discussions"),
702
+ "papers_count": ctx._overview_count(overview_item, "papers"),
703
+ "upvotes_count": ctx._overview_count(overview_item, "upvotes"),
704
+ "organizations": overview_item.get("orgs"),
705
+ "is_pro": overview_item.get("is_pro"),
706
+ }
707
+ if "likes" in requested_sections:
708
+ likes = await ctx.call_helper(
709
+ "hf_user_likes",
710
+ username=resolved_handle,
711
+ limit=likes_lim,
712
+ scan_limit=USER_SUMMARY_LIKES_SCAN_LIMIT,
713
+ count_only=likes_lim == 0,
714
+ sort="liked_at",
715
+ fields=[
716
+ "liked_at",
717
+ "repo_id",
718
+ "repo_type",
719
+ "repo_author",
720
+ "repo_url",
721
+ ],
722
+ )
723
+ item["likes_sample"] = likes.get("items") if likes.get("ok") is True else []
724
+ if likes.get("ok") is not True:
725
+ section_errors["likes"] = str(
726
+ likes.get("error") or "likes fetch failed"
727
+ )
728
+ if "activity" in requested_sections:
729
+ activity = await ctx.call_helper(
730
+ "hf_recent_activity",
731
+ feed_type="user",
732
+ entity=resolved_handle,
733
+ limit=activity_lim,
734
+ max_pages=USER_SUMMARY_ACTIVITY_MAX_PAGES,
735
+ count_only=activity_lim == 0,
736
+ fields=["timestamp", "event_type", "repo_type", "repo_id"],
737
+ )
738
+ item["activity_sample"] = (
739
+ activity.get("items") if activity.get("ok") is True else []
740
+ )
741
+ if activity.get("ok") is not True:
742
+ section_errors["activity"] = str(
743
+ activity.get("error") or "activity fetch failed"
744
+ )
745
+ return ctx._helper_success(
746
+ start_calls=start_calls,
747
+ source=f"/api/users/{resolved_handle}/overview",
748
+ items=[item],
749
+ scanned=1,
750
+ matched=1,
751
+ returned=1,
752
+ truncated=False,
753
+ handle=resolved_handle,
754
+ entity_type="user",
755
+ include=sorted(requested_sections),
756
+ likes_limit=likes_lim,
757
+ activity_limit=activity_lim,
758
+ section_errors=section_errors or None,
759
+ )
760
+ org_overview = await _hf_org_overview(ctx, resolved_handle)
761
+ if org_overview.get("ok") is True:
762
+ overview_item = ctx._helper_item(org_overview) or {
763
+ "organization": resolved_handle
764
+ }
765
+ item = {
766
+ "handle": str(overview_item.get("organization") or resolved_handle),
767
+ "entity_type": "organization",
768
+ "display_name": overview_item.get("display_name")
769
+ or str(overview_item.get("organization") or resolved_handle),
770
+ "description": overview_item.get("description"),
771
+ "avatar_url": overview_item.get("avatar_url"),
772
+ "website_url": overview_item.get("website_url"),
773
+ "followers_count": ctx._overview_count(overview_item, "followers"),
774
+ "members_count": ctx._overview_count(overview_item, "members"),
775
+ "models_count": ctx._overview_count(overview_item, "models"),
776
+ "datasets_count": ctx._overview_count(overview_item, "datasets"),
777
+ "spaces_count": ctx._overview_count(overview_item, "spaces"),
778
+ }
779
+ return ctx._helper_success(
780
+ start_calls=start_calls,
781
+ source=f"/api/organizations/{resolved_handle}/overview",
782
+ items=[item],
783
+ scanned=1,
784
+ matched=1,
785
+ returned=1,
786
+ truncated=False,
787
+ handle=resolved_handle,
788
+ entity_type="organization",
789
+ include=[],
790
+ ignored_includes=sorted(requested_sections) or None,
791
+ )
792
+ error = (
793
+ user_overview.get("error")
794
+ or org_overview.get("error")
795
+ or "profile fetch failed"
796
+ )
797
+ return ctx._helper_error(
798
+ start_calls=start_calls,
799
+ source=f"/api/profiles/{resolved_handle}",
800
+ error=error,
801
+ handle=resolved_handle,
802
+ )
803
+
804
+
805
+ async def hf_user_graph(
806
+ ctx: HelperRuntimeContext,
807
+ username: str | None = None,
808
+ relation: str = "followers",
809
+ limit: int | None = None,
810
+ scan_limit: int | None = None,
811
+ count_only: bool = False,
812
+ pro_only: bool | None = None,
813
+ where: dict[str, Any] | None = None,
814
+ fields: list[str] | None = None,
815
+ ) -> dict[str, Any]:
816
+ start_calls = ctx.call_count["n"]
817
+ rel = str(relation or "").strip().lower() or "followers"
818
+ if rel not in {"followers", "following"}:
819
+ return ctx._helper_error(
820
+ start_calls=start_calls,
821
+ source="/api/users/<u>/followers",
822
+ error="relation must be 'followers' or 'following'",
823
+ )
824
+ resolved_username, resolve_error = await resolve_username_or_current(ctx, username)
825
+ if resolve_error:
826
+ return ctx._helper_error(
827
+ start_calls=start_calls,
828
+ source=f"/api/users/<u>/{rel}",
829
+ error=resolve_error,
830
+ relation=rel,
831
+ )
832
+ if not isinstance(resolved_username, str):
833
+ return ctx._helper_error(
834
+ start_calls=start_calls,
835
+ source=f"/api/users/<u>/{rel}",
836
+ error="username is required",
837
+ relation=rel,
838
+ )
839
+ return await _user_graph_helper(
840
+ ctx,
841
+ rel,
842
+ resolved_username,
843
+ pro_only,
844
+ limit,
845
+ scan_limit,
846
+ count_only,
847
+ where,
848
+ fields,
849
+ helper_name="hf_user_graph",
850
+ )
851
+
852
+
853
+ def register_profile_helpers(
854
+ ctx: HelperRuntimeContext,
855
+ ) -> dict[str, Callable[..., Any]]:
856
+ return {
857
+ "hf_whoami": partial(hf_whoami, ctx),
858
+ "hf_org_members": partial(hf_org_members, ctx),
859
+ "hf_profile_summary": partial(hf_profile_summary, ctx),
860
+ "hf_user_graph": partial(hf_user_graph, ctx),
861
+ }
monty_api/helpers/repos.py ADDED
@@ -0,0 +1,1364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917
4
+ from itertools import islice
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+ from ..context_types import HelperRuntimeContext
7
+ from ..helper_contracts import repo_expand_alias_map
8
+ from ..constants import (
9
+ ACTOR_CANONICAL_FIELDS,
10
+ DAILY_PAPER_CANONICAL_FIELDS,
11
+ EXHAUSTIVE_HELPER_RETURN_HARD_CAP,
12
+ LIKES_ENRICHMENT_MAX_REPOS,
13
+ LIKES_RANKING_WINDOW_DEFAULT,
14
+ LIKES_SCAN_LIMIT_CAP,
15
+ OUTPUT_ITEMS_TRUNCATION_LIMIT,
16
+ REPO_CANONICAL_FIELDS,
17
+ SELECTIVE_ENDPOINT_RETURN_HARD_CAP,
18
+ TRENDING_ENDPOINT_MAX_LIMIT,
19
+ USER_LIKES_CANONICAL_FIELDS,
20
+ )
21
+ from ..registry import (
22
+ REPO_SEARCH_DEFAULT_EXPAND,
23
+ REPO_SEARCH_EXTRA_ARGS,
24
+ TRENDING_DEFAULT_FIELDS,
25
+ )
26
+
27
+
28
+ from .common import resolve_username_or_current
29
+
30
+ from functools import partial
31
+
32
+ if TYPE_CHECKING:
33
+ from huggingface_hub import HfApi
34
+
35
+
36
+ def _sanitize_repo_expand_values(
37
+ repo_type: str, raw_expand: Any
38
+ ) -> tuple[list[str] | None, list[str], str | None]:
39
+ if raw_expand is None:
40
+ return (None, [], None)
41
+ if isinstance(raw_expand, str):
42
+ requested_values = [raw_expand]
43
+ elif isinstance(raw_expand, (list, tuple, set)):
44
+ requested_values = list(raw_expand)
45
+ else:
46
+ return (None, [], "expand must be a string or a list of strings")
47
+
48
+ cleaned: list[str] = []
49
+ for value in requested_values:
50
+ value_str = str(value).strip()
51
+ if value_str and value_str not in cleaned:
52
+ cleaned.append(value_str)
53
+
54
+ alias_map = repo_expand_alias_map(repo_type)
55
+ dropped = [value for value in cleaned if value not in alias_map]
56
+ deduped_kept: list[str] = []
57
+ for value in cleaned:
58
+ resolved = alias_map.get(value)
59
+ if resolved is None or resolved in deduped_kept:
60
+ continue
61
+ deduped_kept.append(resolved)
62
+ return (deduped_kept or None, dropped, None)
63
+
64
+
65
+ def _resolve_repo_search_types(
66
+ ctx: HelperRuntimeContext,
67
+ *,
68
+ repo_type: str | None,
69
+ repo_types: list[str] | None,
70
+ default_repo_type: str = "model",
71
+ ) -> tuple[list[str] | None, str | None]:
72
+ if repo_type is not None and repo_types is not None:
73
+ return (None, "Pass either repo_type or repo_types, not both")
74
+
75
+ if repo_types is None:
76
+ raw_type = str(repo_type or "").strip()
77
+ if not raw_type:
78
+ return ([default_repo_type], None)
79
+ canonical = ctx._canonical_repo_type(raw_type, default="")
80
+ if canonical not in {"model", "dataset", "space"}:
81
+ return (None, f"Unsupported repo_type '{repo_type}'")
82
+ return ([canonical], None)
83
+
84
+ raw_types = ctx._coerce_str_list(repo_types)
85
+ if not raw_types:
86
+ return (None, "repo_types must not be empty")
87
+
88
+ requested_repo_types: list[str] = []
89
+ for raw in raw_types:
90
+ canonical = ctx._canonical_repo_type(raw, default="")
91
+ if canonical not in {"model", "dataset", "space"}:
92
+ return (None, f"Unsupported repo_type '{raw}'")
93
+ if canonical not in requested_repo_types:
94
+ requested_repo_types.append(canonical)
95
+ return (requested_repo_types, None)
96
+
97
+
98
+ def _clean_repo_search_text(value: str | None) -> str | None:
99
+ cleaned = str(value or "").strip()
100
+ return cleaned or None
101
+
102
+
103
+ def _normalize_repo_search_filter(
104
+ ctx: HelperRuntimeContext, value: str | list[str] | None
105
+ ) -> tuple[list[str] | None, str | None]:
106
+ if value is None:
107
+ return (None, None)
108
+ try:
109
+ normalized = ctx._coerce_str_list(value)
110
+ except ValueError:
111
+ return (None, "filter must be a string or a list of strings")
112
+ return (normalized or None, None)
113
+
114
+
115
+ def _build_repo_search_extra_args(
116
+ repo_type: str, **candidate_args: Any
117
+ ) -> tuple[dict[str, Any], list[str], str | None]:
118
+ normalized: dict[str, Any] = {}
119
+ for key, value in candidate_args.items():
120
+ if value is None:
121
+ continue
122
+ if key in {"card_data", "cardData"}:
123
+ if value:
124
+ normalized["cardData"] = True
125
+ continue
126
+ if key in {"num_params", "num_parameters"}:
127
+ normalized["num_parameters"] = value
128
+ continue
129
+ if key in {"fetch_config", "linked"}:
130
+ if value:
131
+ normalized[key] = True
132
+ continue
133
+ normalized[key] = value
134
+
135
+ allowed_extra = REPO_SEARCH_EXTRA_ARGS.get(repo_type, set())
136
+ unsupported = sorted(str(key) for key in normalized if str(key) not in allowed_extra)
137
+ if unsupported:
138
+ return (
139
+ {},
140
+ [],
141
+ f"Unsupported search args for repo_type='{repo_type}': {unsupported}. Allowed args: {sorted(allowed_extra)}",
142
+ )
143
+
144
+ dropped_expand: list[str] = []
145
+ if "expand" in normalized:
146
+ kept_expand, dropped_expand, expand_error = _sanitize_repo_expand_values(
147
+ repo_type, normalized.get("expand")
148
+ )
149
+ if expand_error:
150
+ return ({}, [], expand_error)
151
+ if kept_expand is None:
152
+ normalized.pop("expand", None)
153
+ else:
154
+ normalized["expand"] = kept_expand
155
+
156
+ if not any(
157
+ key in normalized for key in ("expand", "full", "cardData", "fetch_config")
158
+ ):
159
+ normalized["expand"] = list(REPO_SEARCH_DEFAULT_EXPAND[repo_type])
160
+
161
+ return (normalized, dropped_expand, None)
162
+
163
+
164
+ def _normalize_user_likes_sort(sort: str | None) -> tuple[str | None, str | None]:
165
+ normalized = str(sort or "liked_at").strip() or "liked_at"
166
+ if normalized not in {"liked_at", "repo_likes", "repo_downloads"}:
167
+ return (None, "sort must be one of liked_at, repo_likes, repo_downloads")
168
+ return (normalized, None)
169
+
170
+
171
+ async def _run_repo_search(
172
+ ctx: HelperRuntimeContext,
173
+ *,
174
+ helper_name: str,
175
+ requested_repo_types: list[str],
176
+ search: str | None,
177
+ filter: str | list[str] | None,
178
+ author: str | None,
179
+ sort: str | None,
180
+ limit: int,
181
+ fields: list[str] | None,
182
+ post_filter: dict[str, Any] | None,
183
+ extra_args_by_type: dict[str, dict[str, Any]] | None = None,
184
+ ) -> dict[str, Any]:
185
+ start_calls = ctx.call_count["n"]
186
+ default_limit = ctx._policy_int(helper_name, "default_limit", 100)
187
+ max_limit = ctx._policy_int(
188
+ helper_name, "max_limit", SELECTIVE_ENDPOINT_RETURN_HARD_CAP
189
+ )
190
+ filter_list, filter_error = _normalize_repo_search_filter(ctx, filter)
191
+ if filter_error:
192
+ return ctx._helper_error(
193
+ start_calls=start_calls,
194
+ source="/api/repos",
195
+ error=filter_error,
196
+ )
197
+
198
+ term = _clean_repo_search_text(search)
199
+ author_clean = _clean_repo_search_text(author)
200
+ requested_limit = limit
201
+ applied_limit = ctx._clamp_int(
202
+ limit,
203
+ default=default_limit,
204
+ minimum=1,
205
+ maximum=max_limit,
206
+ )
207
+ limit_meta = ctx._derive_limit_metadata(
208
+ requested_limit=requested_limit,
209
+ applied_limit=applied_limit,
210
+ default_limit_used=limit == default_limit,
211
+ )
212
+ hard_cap_applied = bool(limit_meta.get("hard_cap_applied"))
213
+
214
+ sort_keys: dict[str, str | None] = {}
215
+ for repo_type in requested_repo_types:
216
+ sort_key, sort_error = ctx._normalize_repo_sort_key(repo_type, sort)
217
+ if sort_error:
218
+ return ctx._helper_error(
219
+ start_calls=start_calls,
220
+ source=f"/api/{repo_type}s",
221
+ error=sort_error,
222
+ )
223
+ sort_keys[repo_type] = sort_key
224
+
225
+ all_items: list[dict[str, Any]] = []
226
+ scanned = 0
227
+ source_endpoints: list[str] = []
228
+ limit_boundary_hit = False
229
+ ignored_expand: dict[str, list[str]] = {}
230
+ api = ctx._get_hf_api_client()
231
+
232
+ for repo_type in requested_repo_types:
233
+ endpoint = f"/api/{repo_type}s"
234
+ source_endpoints.append(endpoint)
235
+ raw_extra_args = dict((extra_args_by_type or {}).get(repo_type, {}))
236
+ extra_args, dropped_expand, extra_error = _build_repo_search_extra_args(
237
+ repo_type,
238
+ **raw_extra_args,
239
+ )
240
+ if extra_error:
241
+ return ctx._helper_error(
242
+ start_calls=start_calls,
243
+ source=endpoint,
244
+ error=extra_error,
245
+ )
246
+ if dropped_expand:
247
+ ignored_expand[repo_type] = dropped_expand
248
+ try:
249
+ payload = ctx._host_hf_call(
250
+ endpoint,
251
+ lambda repo_type=repo_type, extra_args=extra_args: ctx._repo_list_call(
252
+ api,
253
+ repo_type,
254
+ search=term,
255
+ author=author_clean,
256
+ filter=filter_list,
257
+ sort=sort_keys[repo_type],
258
+ limit=applied_limit,
259
+ **extra_args,
260
+ ),
261
+ )
262
+ except Exception as e:
263
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=e)
264
+ scanned += len(payload)
265
+ if len(payload) >= applied_limit:
266
+ limit_boundary_hit = True
267
+ all_items.extend(
268
+ ctx._normalize_repo_search_row(row, repo_type)
269
+ for row in payload[:applied_limit]
270
+ )
271
+
272
+ try:
273
+ all_items = ctx._apply_where(
274
+ all_items, post_filter, allowed_fields=REPO_CANONICAL_FIELDS
275
+ )
276
+ except ValueError as exc:
277
+ return ctx._helper_error(
278
+ start_calls=start_calls,
279
+ source="/api/repos",
280
+ error=exc,
281
+ )
282
+ combined_sort_key = next(iter(sort_keys.values()), None)
283
+ all_items = ctx._sort_repo_rows(all_items, combined_sort_key)
284
+ matched = len(all_items)
285
+ try:
286
+ all_items = ctx._project_repo_items(all_items[:applied_limit], fields)
287
+ except ValueError as exc:
288
+ return ctx._helper_error(
289
+ start_calls=start_calls,
290
+ source="/api/repos",
291
+ error=exc,
292
+ )
293
+
294
+ more_available: bool | str = False
295
+ truncated = False
296
+ truncated_by = "none"
297
+ next_request_hint: str | None = None
298
+ if hard_cap_applied and scanned >= applied_limit:
299
+ truncated = True
300
+ truncated_by = "hard_cap"
301
+ more_available = "unknown"
302
+ next_request_hint = f"Increase limit above {applied_limit} to improve coverage"
303
+ elif limit_boundary_hit:
304
+ more_available = "unknown"
305
+ next_request_hint = (
306
+ f"Increase limit above {applied_limit} to check whether more rows exist"
307
+ )
308
+
309
+ return ctx._helper_success(
310
+ start_calls=start_calls,
311
+ source=",".join(source_endpoints),
312
+ items=all_items,
313
+ helper=helper_name,
314
+ search=term,
315
+ repo_types=requested_repo_types,
316
+ filter=filter_list,
317
+ sort=combined_sort_key,
318
+ author=author_clean,
319
+ limit=applied_limit,
320
+ post_filter=post_filter if isinstance(post_filter, dict) and post_filter else None,
321
+ scanned=scanned,
322
+ matched=matched,
323
+ returned=len(all_items),
324
+ truncated=truncated,
325
+ truncated_by=truncated_by,
326
+ more_available=more_available,
327
+ limit_boundary_hit=limit_boundary_hit,
328
+ next_request_hint=next_request_hint,
329
+ ignored_expand=ignored_expand or None,
330
+ **limit_meta,
331
+ )
332
+
333
+
334
+ async def hf_models_search(
335
+ ctx: HelperRuntimeContext,
336
+ search: str | None = None,
337
+ filter: str | list[str] | None = None,
338
+ author: str | None = None,
339
+ apps: str | list[str] | None = None,
340
+ gated: bool | None = None,
341
+ inference: str | None = None,
342
+ inference_provider: str | list[str] | None = None,
343
+ model_name: str | None = None,
344
+ trained_dataset: str | list[str] | None = None,
345
+ pipeline_tag: str | None = None,
346
+ num_params: str | None = None,
347
+ emissions_thresholds: tuple[float, float] | None = None,
348
+ sort: str | None = None,
349
+ limit: int = 100,
350
+ expand: list[str] | None = None,
351
+ full: bool | None = None,
352
+ card_data: bool = False,
353
+ fetch_config: bool = False,
354
+ fields: list[str] | None = None,
355
+ post_filter: dict[str, Any] | None = None,
356
+ ) -> dict[str, Any]:
357
+ return await _run_repo_search(
358
+ ctx,
359
+ helper_name="hf_models_search",
360
+ requested_repo_types=["model"],
361
+ search=search,
362
+ filter=filter,
363
+ author=author,
364
+ sort=sort,
365
+ limit=limit,
366
+ fields=fields,
367
+ post_filter=post_filter,
368
+ extra_args_by_type={
369
+ "model": {
370
+ "apps": apps,
371
+ "gated": gated,
372
+ "inference": inference,
373
+ "inference_provider": inference_provider,
374
+ "model_name": model_name,
375
+ "trained_dataset": trained_dataset,
376
+ "pipeline_tag": pipeline_tag,
377
+ "num_params": num_params,
378
+ "emissions_thresholds": emissions_thresholds,
379
+ "expand": expand,
380
+ "full": full,
381
+ "card_data": card_data,
382
+ "fetch_config": fetch_config,
383
+ }
384
+ },
385
+ )
386
+
387
+
388
+ async def hf_datasets_search(
389
+ ctx: HelperRuntimeContext,
390
+ search: str | None = None,
391
+ filter: str | list[str] | None = None,
392
+ author: str | None = None,
393
+ benchmark: str | bool | None = None,
394
+ dataset_name: str | None = None,
395
+ gated: bool | None = None,
396
+ language_creators: str | list[str] | None = None,
397
+ language: str | list[str] | None = None,
398
+ multilinguality: str | list[str] | None = None,
399
+ size_categories: str | list[str] | None = None,
400
+ task_categories: str | list[str] | None = None,
401
+ task_ids: str | list[str] | None = None,
402
+ sort: str | None = None,
403
+ limit: int = 100,
404
+ expand: list[str] | None = None,
405
+ full: bool | None = None,
406
+ fields: list[str] | None = None,
407
+ post_filter: dict[str, Any] | None = None,
408
+ ) -> dict[str, Any]:
409
+ return await _run_repo_search(
410
+ ctx,
411
+ helper_name="hf_datasets_search",
412
+ requested_repo_types=["dataset"],
413
+ search=search,
414
+ filter=filter,
415
+ author=author,
416
+ sort=sort,
417
+ limit=limit,
418
+ fields=fields,
419
+ post_filter=post_filter,
420
+ extra_args_by_type={
421
+ "dataset": {
422
+ "benchmark": benchmark,
423
+ "dataset_name": dataset_name,
424
+ "gated": gated,
425
+ "language_creators": language_creators,
426
+ "language": language,
427
+ "multilinguality": multilinguality,
428
+ "size_categories": size_categories,
429
+ "task_categories": task_categories,
430
+ "task_ids": task_ids,
431
+ "expand": expand,
432
+ "full": full,
433
+ }
434
+ },
435
+ )
436
+
437
+
438
+ async def hf_spaces_search(
439
+ ctx: HelperRuntimeContext,
440
+ search: str | None = None,
441
+ filter: str | list[str] | None = None,
442
+ author: str | None = None,
443
+ datasets: str | list[str] | None = None,
444
+ models: str | list[str] | None = None,
445
+ linked: bool = False,
446
+ sort: str | None = None,
447
+ limit: int = 100,
448
+ expand: list[str] | None = None,
449
+ full: bool | None = None,
450
+ fields: list[str] | None = None,
451
+ post_filter: dict[str, Any] | None = None,
452
+ ) -> dict[str, Any]:
453
+ return await _run_repo_search(
454
+ ctx,
455
+ helper_name="hf_spaces_search",
456
+ requested_repo_types=["space"],
457
+ search=search,
458
+ filter=filter,
459
+ author=author,
460
+ sort=sort,
461
+ limit=limit,
462
+ fields=fields,
463
+ post_filter=post_filter,
464
+ extra_args_by_type={
465
+ "space": {
466
+ "datasets": datasets,
467
+ "models": models,
468
+ "linked": linked,
469
+ "expand": expand,
470
+ "full": full,
471
+ }
472
+ },
473
+ )
474
+
475
+
476
+ async def hf_repo_search(
477
+ ctx: HelperRuntimeContext,
478
+ search: str | None = None,
479
+ repo_type: str | None = None,
480
+ repo_types: list[str] | None = None,
481
+ filter: str | list[str] | None = None,
482
+ author: str | None = None,
483
+ sort: str | None = None,
484
+ limit: int = 100,
485
+ fields: list[str] | None = None,
486
+ post_filter: dict[str, Any] | None = None,
487
+ ) -> dict[str, Any]:
488
+ start_calls = ctx.call_count["n"]
489
+ requested_repo_types, type_error = _resolve_repo_search_types(
490
+ ctx,
491
+ repo_type=repo_type,
492
+ repo_types=repo_types,
493
+ )
494
+ if type_error:
495
+ return ctx._helper_error(
496
+ start_calls=start_calls,
497
+ source="/api/repos",
498
+ error=type_error,
499
+ )
500
+ if not requested_repo_types:
501
+ return ctx._helper_error(
502
+ start_calls=start_calls,
503
+ source="/api/repos",
504
+ error="repo_type or repo_types is required",
505
+ )
506
+ return await _run_repo_search(
507
+ ctx,
508
+ helper_name="hf_repo_search",
509
+ requested_repo_types=requested_repo_types,
510
+ search=search,
511
+ filter=filter,
512
+ author=author,
513
+ sort=sort,
514
+ limit=limit,
515
+ fields=fields,
516
+ post_filter=post_filter,
517
+ )
518
+
519
+
520
+ async def hf_user_likes(
521
+ ctx: HelperRuntimeContext,
522
+ username: str | None = None,
523
+ repo_types: list[str] | None = None,
524
+ limit: int | None = None,
525
+ scan_limit: int | None = None,
526
+ count_only: bool = False,
527
+ where: dict[str, Any] | None = None,
528
+ fields: list[str] | None = None,
529
+ sort: str | None = None,
530
+ ranking_window: int | None = None,
531
+ ) -> dict[str, Any]:
532
+ start_calls = ctx.call_count["n"]
533
+ default_limit = ctx._policy_int("hf_user_likes", "default_limit", 100)
534
+ scan_cap = ctx._policy_int("hf_user_likes", "scan_max", LIKES_SCAN_LIMIT_CAP)
535
+ ranking_default = ctx._policy_int(
536
+ "hf_user_likes", "ranking_default", LIKES_RANKING_WINDOW_DEFAULT
537
+ )
538
+ enrich_cap = ctx._policy_int(
539
+ "hf_user_likes", "enrich_max", LIKES_ENRICHMENT_MAX_REPOS
540
+ )
541
+ resolved_username, resolve_error = await resolve_username_or_current(ctx, username)
542
+ if resolve_error:
543
+ return ctx._helper_error(
544
+ start_calls=start_calls,
545
+ source="/api/users/<u>/likes",
546
+ error=resolve_error,
547
+ )
548
+ if not isinstance(resolved_username, str):
549
+ return ctx._helper_error(
550
+ start_calls=start_calls,
551
+ source="/api/users/<u>/likes",
552
+ error="username is required",
553
+ )
554
+ sort_key, sort_error = _normalize_user_likes_sort(sort)
555
+ if sort_error:
556
+ return ctx._helper_error(
557
+ start_calls=start_calls,
558
+ source=f"/api/users/{resolved_username}/likes",
559
+ error=sort_error,
560
+ )
561
+ if sort_key is None:
562
+ return ctx._helper_error(
563
+ start_calls=start_calls,
564
+ source=f"/api/users/{resolved_username}/likes",
565
+ error="sort must be one of liked_at, repo_likes, repo_downloads",
566
+ )
567
+ limit_plan = ctx._resolve_exhaustive_limits(
568
+ limit=limit,
569
+ count_only=count_only,
570
+ default_limit=default_limit,
571
+ max_limit=EXHAUSTIVE_HELPER_RETURN_HARD_CAP,
572
+ scan_limit=scan_limit,
573
+ scan_cap=scan_cap,
574
+ )
575
+ applied_limit = int(limit_plan["applied_limit"])
576
+ scan_lim = int(limit_plan["applied_scan_limit"])
577
+ try:
578
+ normalized_where = ctx._normalize_where(
579
+ where, allowed_fields=USER_LIKES_CANONICAL_FIELDS
580
+ )
581
+ except ValueError as exc:
582
+ return ctx._helper_error(
583
+ start_calls=start_calls,
584
+ source=f"/api/users/{resolved_username}/likes",
585
+ error=exc,
586
+ )
587
+ allowed_repo_types: set[str] | None = None
588
+ try:
589
+ raw_repo_types: list[str] = (
590
+ ctx._coerce_str_list(repo_types) if repo_types is not None else []
591
+ )
592
+ except ValueError as e:
593
+ return ctx._helper_error(
594
+ start_calls=start_calls,
595
+ source=f"/api/users/{resolved_username}/likes",
596
+ error=e,
597
+ )
598
+ if raw_repo_types:
599
+ allowed_repo_types = set()
600
+ for raw in raw_repo_types:
601
+ canonical = ctx._canonical_repo_type(raw, default="")
602
+ if canonical not in {"model", "dataset", "space"}:
603
+ return ctx._helper_error(
604
+ start_calls=start_calls,
605
+ source=f"/api/users/{resolved_username}/likes",
606
+ error=f"Unsupported repo_type '{raw}'",
607
+ )
608
+ allowed_repo_types.add(canonical)
609
+ endpoint = f"/api/users/{resolved_username}/likes"
610
+ resp = ctx._host_raw_call(endpoint, params={"limit": scan_lim})
611
+ if not resp.get("ok"):
612
+ return ctx._helper_error(
613
+ start_calls=start_calls,
614
+ source=endpoint,
615
+ error=resp.get("error") or "likes fetch failed",
616
+ )
617
+ payload = resp.get("data") if isinstance(resp.get("data"), list) else []
618
+ scanned_rows = payload[:scan_lim]
619
+ matched_rows: list[tuple[int, dict[str, Any]]] = []
620
+ for row in scanned_rows:
621
+ if not isinstance(row, dict):
622
+ continue
623
+ repo = row.get("repo") if isinstance(row.get("repo"), dict) else {}
624
+ repo_data = row.get("repoData") if isinstance(row.get("repoData"), dict) else {}
625
+ repo_id = repo_data.get("id") or repo_data.get("name") or repo.get("name")
626
+ if not isinstance(repo_id, str) or not repo_id:
627
+ continue
628
+ repo_type = ctx._canonical_repo_type(
629
+ repo_data.get("type") or repo.get("type"), default=""
630
+ )
631
+ if not repo_type:
632
+ repo_type = ctx._canonical_repo_type(repo.get("type"), default="model")
633
+ if allowed_repo_types is not None and repo_type not in allowed_repo_types:
634
+ continue
635
+ repo_author = repo_data.get("author")
636
+ if not isinstance(repo_author, str) and "/" in repo_id:
637
+ repo_author = repo_id.split("/", 1)[0]
638
+ item = {
639
+ "liked_at": row.get("likedAt") or row.get("createdAt"),
640
+ "repo_id": repo_id,
641
+ "repo_type": repo_type,
642
+ "repo_author": repo_author,
643
+ "repo_likes": ctx._as_int(repo_data.get("likes")),
644
+ "repo_downloads": ctx._as_int(repo_data.get("downloads")),
645
+ "repo_url": ctx._repo_web_url(repo_type, repo_id),
646
+ }
647
+ if not ctx._item_matches_where(item, normalized_where):
648
+ continue
649
+ matched_rows.append((len(matched_rows), item))
650
+ matched = len(matched_rows)
651
+ scan_exhaustive = len(payload) < scan_lim
652
+ exact_count = scan_exhaustive
653
+ total_matched = matched
654
+ total = total_matched
655
+ effective_ranking_window: int | None = None
656
+ ranking_window_hit = False
657
+ ranking_window_applied = False
658
+ ranking_next_request_hint: str | None = None
659
+ ranking_complete = sort_key == "liked_at" and exact_count
660
+ enriched = 0
661
+ selected_pairs: list[tuple[int, dict[str, Any]]]
662
+ if count_only:
663
+ selected_pairs = []
664
+ ranking_complete = False if matched > 0 else exact_count
665
+ elif sort_key == "liked_at":
666
+ selected_pairs = matched_rows[:applied_limit]
667
+ else:
668
+ metric = str(sort_key)
669
+ requested_window = (
670
+ ranking_window if ranking_window is not None else ranking_default
671
+ )
672
+ effective_ranking_window = ctx._clamp_int(
673
+ requested_window, default=ranking_default, minimum=1, maximum=enrich_cap
674
+ )
675
+ ranking_window_applied = (
676
+ ranking_window is not None
677
+ and effective_ranking_window != int(ranking_window)
678
+ )
679
+ shortlist_size = min(effective_ranking_window, matched, scan_lim)
680
+ ranking_window_hit = matched > shortlist_size
681
+ shortlist = matched_rows[:shortlist_size]
682
+ candidates = [
683
+ pair
684
+ for pair in shortlist
685
+ if pair[1].get(metric) is None
686
+ and isinstance(pair[1].get("repo_id"), str)
687
+ and (pair[1].get("repo_type") in {"model", "dataset", "space"})
688
+ ]
689
+ enrich_budget = min(len(candidates), ctx._budget_remaining(), shortlist_size)
690
+ for _, item in candidates[:enrich_budget]:
691
+ repo_type = str(item.get("repo_type"))
692
+ repo_id = str(item.get("repo_id"))
693
+ detail_endpoint = f"/api/{ctx._canonical_repo_type(repo_type)}s/{repo_id}"
694
+ try:
695
+ detail = ctx._host_hf_call(
696
+ detail_endpoint,
697
+ lambda rt=repo_type, rid=repo_id: ctx._repo_detail_call(
698
+ ctx._get_hf_api_client(), rt, rid
699
+ ),
700
+ )
701
+ except Exception:
702
+ continue
703
+ likes = ctx._as_int(getattr(detail, "likes", None))
704
+ downloads = ctx._as_int(getattr(detail, "downloads", None))
705
+ if likes is not None:
706
+ item["repo_likes"] = likes
707
+ if downloads is not None:
708
+ item["repo_downloads"] = downloads
709
+ enriched += 1
710
+
711
+ def _ranking_key(pair: tuple[int, dict[str, Any]]) -> tuple[int, int, int]:
712
+ idx, row = pair
713
+ metric_value = ctx._as_int(row.get(metric))
714
+ if metric_value is None:
715
+ return (1, 0, idx)
716
+ return (0, -metric_value, idx)
717
+
718
+ ranked_shortlist = sorted(shortlist, key=_ranking_key)
719
+ selected_pairs = ranked_shortlist[:applied_limit]
720
+ ranking_complete = (
721
+ exact_count
722
+ and shortlist_size >= matched
723
+ and (len(candidates) <= enrich_budget)
724
+ )
725
+ if not ranking_complete:
726
+ if ranking_window_hit:
727
+ if effective_ranking_window < enrich_cap:
728
+ ranking_next_request_hint = (
729
+ f"Increase ranking_window above {effective_ranking_window} "
730
+ "for broader popularity reranking"
731
+ )
732
+ else:
733
+ ranking_next_request_hint = (
734
+ f"Popularity reranking is capped at {effective_ranking_window} "
735
+ "candidate repos per call"
736
+ )
737
+ elif len(candidates) > enrich_budget:
738
+ ranking_next_request_hint = (
739
+ f"Popularity reranking exhausted detail budget after {enrich_budget} "
740
+ "repo enrichments"
741
+ )
742
+ try:
743
+ items = ctx._project_user_like_items([row for _, row in selected_pairs], fields)
744
+ except ValueError as exc:
745
+ return ctx._helper_error(
746
+ start_calls=start_calls,
747
+ source=endpoint,
748
+ error=exc,
749
+ )
750
+ popularity_present = sum(
751
+ (1 for _, row in selected_pairs if row.get("repo_likes") is not None)
752
+ )
753
+ sample_complete = (
754
+ exact_count
755
+ and applied_limit >= matched
756
+ and (sort_key == "liked_at" or ranking_complete)
757
+ and (not count_only or matched == 0)
758
+ )
759
+ scan_limit_hit = not scan_exhaustive and len(payload) >= scan_lim
760
+ more_available = ctx._derive_more_available(
761
+ sample_complete=sample_complete,
762
+ exact_count=exact_count,
763
+ returned=len(items),
764
+ total=total,
765
+ )
766
+ if scan_limit_hit:
767
+ more_available = "unknown" if allowed_repo_types is not None or where else True
768
+ meta = ctx._build_exhaustive_result_meta(
769
+ base_meta={
770
+ "scanned": len(scanned_rows),
771
+ "total": total,
772
+ "total_available": len(payload),
773
+ "total_matched": total_matched,
774
+ "count_source": "scan",
775
+ "lower_bound": not exact_count,
776
+ "enriched": enriched,
777
+ "popularity_present": popularity_present,
778
+ "sort_applied": sort_key,
779
+ "ranking_window": effective_ranking_window,
780
+ "requested_ranking_window": ranking_window,
781
+ "ranking_window_applied": ranking_window_applied,
782
+ "ranking_window_hit": ranking_window_hit,
783
+ "ranking_next_request_hint": ranking_next_request_hint,
784
+ "ranking_complete": ranking_complete,
785
+ "username": resolved_username,
786
+ },
787
+ limit_plan=limit_plan,
788
+ matched_count=matched,
789
+ returned_count=len(items),
790
+ exact_count=exact_count,
791
+ count_only=count_only,
792
+ sample_complete=sample_complete,
793
+ more_available=more_available,
794
+ scan_limit_hit=scan_limit_hit,
795
+ truncated_extra=sort_key != "liked_at" and (not ranking_complete),
796
+ )
797
+ return ctx._helper_success(
798
+ start_calls=start_calls, source=endpoint, items=items, meta=meta
799
+ )
800
+
801
+
802
+ async def hf_repo_likers(
803
+ ctx: HelperRuntimeContext,
804
+ repo_id: str,
805
+ repo_type: str,
806
+ limit: int | None = None,
807
+ count_only: bool = False,
808
+ pro_only: bool | None = None,
809
+ where: dict[str, Any] | None = None,
810
+ fields: list[str] | None = None,
811
+ ) -> dict[str, Any]:
812
+ start_calls = ctx.call_count["n"]
813
+ rid = str(repo_id or "").strip()
814
+ if not rid:
815
+ return ctx._helper_error(
816
+ start_calls=start_calls,
817
+ source="/api/repos/<repo>/likers",
818
+ error="repo_id is required",
819
+ )
820
+ rt = ctx._canonical_repo_type(repo_type, default="")
821
+ if rt not in {"model", "dataset", "space"}:
822
+ return ctx._helper_error(
823
+ start_calls=start_calls,
824
+ source=f"/api/repos/{rid}/likers",
825
+ error=f"Unsupported repo_type '{repo_type}'",
826
+ repo_id=rid,
827
+ )
828
+ default_limit = ctx._policy_int("hf_repo_likers", "default_limit", 1000)
829
+ requested_limit = limit
830
+ default_limit_used = requested_limit is None and (not count_only)
831
+ has_where = isinstance(where, dict) and bool(where)
832
+ endpoint = f"/api/{rt}s/{rid}/likers"
833
+ resp = ctx._host_raw_call(endpoint)
834
+ if not resp.get("ok"):
835
+ return ctx._helper_error(
836
+ start_calls=start_calls,
837
+ source=endpoint,
838
+ error=resp.get("error") or "repo likers fetch failed",
839
+ repo_id=rid,
840
+ repo_type=rt,
841
+ )
842
+ payload = resp.get("data") if isinstance(resp.get("data"), list) else []
843
+ try:
844
+ normalized_where = ctx._normalize_where(
845
+ where, allowed_fields=ACTOR_CANONICAL_FIELDS
846
+ )
847
+ except ValueError as exc:
848
+ return ctx._helper_error(
849
+ start_calls=start_calls,
850
+ source=endpoint,
851
+ error=exc,
852
+ repo_id=rid,
853
+ repo_type=rt,
854
+ )
855
+ normalized: list[dict[str, Any]] = []
856
+ for row in payload:
857
+ if not isinstance(row, dict):
858
+ continue
859
+ username = row.get("user") or row.get("username")
860
+ if not isinstance(username, str) or not username:
861
+ continue
862
+ item = {
863
+ "username": username,
864
+ "fullname": row.get("fullname"),
865
+ "type": row.get("type")
866
+ if isinstance(row.get("type"), str) and row.get("type")
867
+ else "user",
868
+ "is_pro": row.get("isPro"),
869
+ }
870
+ if pro_only is True and item.get("is_pro") is not True:
871
+ continue
872
+ if pro_only is False and item.get("is_pro") is True:
873
+ continue
874
+ if not ctx._item_matches_where(item, normalized_where):
875
+ continue
876
+ normalized.append(item)
877
+ if count_only:
878
+ applied_limit = 0
879
+ elif requested_limit is None:
880
+ applied_limit = default_limit
881
+ else:
882
+ try:
883
+ applied_limit = max(0, int(requested_limit))
884
+ except Exception:
885
+ applied_limit = default_limit
886
+ limit_plan = {
887
+ "requested_limit": requested_limit,
888
+ "applied_limit": applied_limit,
889
+ "default_limit_used": default_limit_used,
890
+ "hard_cap_applied": False,
891
+ }
892
+ matched = len(normalized)
893
+ items = [] if count_only else normalized[:applied_limit]
894
+ limit_hit = applied_limit > 0 and matched > applied_limit
895
+ truncated_by = ctx._derive_truncated_by(
896
+ hard_cap=False, limit_hit=limit_hit
897
+ )
898
+ sample_complete = matched <= applied_limit and (not count_only or matched == 0)
899
+ truncated = truncated_by != "none"
900
+ more_available = ctx._derive_more_available(
901
+ sample_complete=sample_complete,
902
+ exact_count=True,
903
+ returned=len(items),
904
+ total=matched,
905
+ )
906
+ try:
907
+ items = ctx._project_actor_items(items, fields)
908
+ except ValueError as exc:
909
+ return ctx._helper_error(
910
+ start_calls=start_calls,
911
+ source=endpoint,
912
+ error=exc,
913
+ repo_id=rid,
914
+ repo_type=rt,
915
+ )
916
+ meta = ctx._build_exhaustive_meta(
917
+ base_meta={
918
+ "scanned": len(payload),
919
+ "matched": matched,
920
+ "returned": len(items),
921
+ "total": matched,
922
+ "total_available": len(payload),
923
+ "total_matched": matched,
924
+ "truncated": truncated,
925
+ "count_source": "likers_list",
926
+ "lower_bound": False,
927
+ "repo_id": rid,
928
+ "repo_type": rt,
929
+ "pro_only": pro_only,
930
+ "where_applied": has_where,
931
+ "upstream_pagination": "none",
932
+ },
933
+ limit_plan=limit_plan,
934
+ sample_complete=sample_complete,
935
+ exact_count=True,
936
+ truncated_by=truncated_by,
937
+ more_available=more_available,
938
+ )
939
+ meta["hard_cap_applied"] = False
940
+ return ctx._helper_success(
941
+ start_calls=start_calls, source=endpoint, items=items, meta=meta
942
+ )
943
+
944
+
945
+ async def hf_repo_discussions(
946
+ ctx: HelperRuntimeContext,
947
+ repo_type: str,
948
+ repo_id: str,
949
+ limit: int = 20,
950
+ fields: list[str] | None = None,
951
+ ) -> dict[str, Any]:
952
+ start_calls = ctx.call_count["n"]
953
+ rt = ctx._canonical_repo_type(repo_type)
954
+ rid = str(repo_id or "").strip()
955
+ if "/" not in rid:
956
+ return ctx._helper_error(
957
+ start_calls=start_calls,
958
+ source="/api/.../discussions",
959
+ error="repo_id must be owner/name",
960
+ )
961
+ lim = ctx._clamp_int(
962
+ limit, default=20, minimum=1, maximum=SELECTIVE_ENDPOINT_RETURN_HARD_CAP
963
+ )
964
+ endpoint = f"/api/{rt}s/{rid}/discussions"
965
+ try:
966
+ discussions = ctx._host_hf_call(
967
+ endpoint,
968
+ lambda: list(
969
+ islice(
970
+ ctx._get_hf_api_client().get_repo_discussions(
971
+ repo_id=rid, repo_type=rt
972
+ ),
973
+ lim,
974
+ )
975
+ ),
976
+ )
977
+ except Exception as e:
978
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=e)
979
+ items: list[dict[str, Any]] = []
980
+ for d in discussions:
981
+ num = ctx._as_int(getattr(d, "num", None))
982
+ items.append(
983
+ {
984
+ "num": num,
985
+ "repo_id": rid,
986
+ "repo_type": rt,
987
+ "title": getattr(d, "title", None),
988
+ "author": getattr(d, "author", None),
989
+ "created_at": str(getattr(d, "created_at", None))
990
+ if getattr(d, "created_at", None) is not None
991
+ else None,
992
+ "status": getattr(d, "status", None),
993
+ "url": getattr(d, "url", None),
994
+ }
995
+ )
996
+ try:
997
+ items = ctx._project_discussion_items(items, fields)
998
+ except ValueError as exc:
999
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=exc)
1000
+ return ctx._helper_success(
1001
+ start_calls=start_calls,
1002
+ source=endpoint,
1003
+ items=items,
1004
+ scanned=len(items),
1005
+ matched=len(items),
1006
+ returned=len(items),
1007
+ truncated=False,
1008
+ total_count=None,
1009
+ )
1010
+
1011
+
1012
+ async def hf_repo_discussion_details(
1013
+ ctx: HelperRuntimeContext,
1014
+ repo_type: str,
1015
+ repo_id: str,
1016
+ discussion_num: int,
1017
+ fields: list[str] | None = None,
1018
+ ) -> dict[str, Any]:
1019
+ start_calls = ctx.call_count["n"]
1020
+ rt = ctx._canonical_repo_type(repo_type)
1021
+ rid = str(repo_id or "").strip()
1022
+ if "/" not in rid:
1023
+ return ctx._helper_error(
1024
+ start_calls=start_calls,
1025
+ source="/api/.../discussions/<num>",
1026
+ error="repo_id must be owner/name",
1027
+ )
1028
+ num = ctx._as_int(discussion_num)
1029
+ if num is None:
1030
+ return ctx._helper_error(
1031
+ start_calls=start_calls,
1032
+ source=f"/api/{rt}s/{rid}/discussions/<num>",
1033
+ error="discussion_num must be an integer",
1034
+ )
1035
+ endpoint = f"/api/{rt}s/{rid}/discussions/{num}"
1036
+ try:
1037
+ detail = ctx._host_hf_call(
1038
+ endpoint,
1039
+ lambda: ctx._get_hf_api_client().get_discussion_details(
1040
+ repo_id=rid, discussion_num=int(num), repo_type=rt
1041
+ ),
1042
+ )
1043
+ except Exception as e:
1044
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=e)
1045
+ comment_events: list[dict[str, Any]] = []
1046
+ raw_events = getattr(detail, "events", None)
1047
+ if isinstance(raw_events, list):
1048
+ for event in raw_events:
1049
+ if str(getattr(event, "type", "")).strip().lower() != "comment":
1050
+ continue
1051
+ comment_events.append(
1052
+ {
1053
+ "author": getattr(event, "author", None),
1054
+ "created_at": ctx._dt_to_str(getattr(event, "created_at", None)),
1055
+ "text": getattr(event, "content", None),
1056
+ "rendered": getattr(event, "rendered", None),
1057
+ }
1058
+ )
1059
+ latest_comment: dict[str, Any] | None = None
1060
+ if comment_events:
1061
+ latest_comment = max(
1062
+ comment_events, key=lambda row: str(row.get("created_at") or "")
1063
+ )
1064
+ item: dict[str, Any] = {
1065
+ "num": num,
1066
+ "repo_id": rid,
1067
+ "repo_type": rt,
1068
+ "title": getattr(detail, "title", None),
1069
+ "author": getattr(detail, "author", None),
1070
+ "created_at": ctx._dt_to_str(getattr(detail, "created_at", None)),
1071
+ "status": getattr(detail, "status", None),
1072
+ "url": getattr(detail, "url", None),
1073
+ "comment_count": len(comment_events),
1074
+ "latest_comment_author": latest_comment.get("author")
1075
+ if latest_comment
1076
+ else None,
1077
+ "latest_comment_created_at": latest_comment.get("created_at")
1078
+ if latest_comment
1079
+ else None,
1080
+ "latest_comment_text": latest_comment.get("text") if latest_comment else None,
1081
+ "latest_comment_html": latest_comment.get("rendered")
1082
+ if latest_comment
1083
+ else None,
1084
+ }
1085
+ try:
1086
+ items = ctx._project_discussion_detail_items([item], fields)
1087
+ except ValueError as exc:
1088
+ return ctx._helper_error(start_calls=start_calls, source=endpoint, error=exc)
1089
+ return ctx._helper_success(
1090
+ start_calls=start_calls,
1091
+ source=endpoint,
1092
+ items=items,
1093
+ scanned=len(comment_events),
1094
+ matched=1,
1095
+ returned=len(items),
1096
+ truncated=False,
1097
+ total_comments=len(comment_events),
1098
+ )
1099
+
1100
+
1101
+ def _resolve_repo_detail_row(
1102
+ ctx: HelperRuntimeContext,
1103
+ api: "HfApi",
1104
+ repo_id: str,
1105
+ attempt_types: list[str],
1106
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
1107
+ rid = str(repo_id or "").strip()
1108
+ if "/" not in rid:
1109
+ return (None, {"repo_id": rid, "error": "repo_id must be owner/name"})
1110
+ resolved_type: str | None = None
1111
+ detail: Any = None
1112
+ last_endpoint = "/api/repos"
1113
+ errors: list[str] = []
1114
+ for rt in attempt_types:
1115
+ endpoint = f"/api/{rt}s/{rid}"
1116
+ last_endpoint = endpoint
1117
+ try:
1118
+ detail = ctx._host_hf_call(
1119
+ endpoint, lambda rt=rt, rid=rid: ctx._repo_detail_call(api, rt, rid)
1120
+ )
1121
+ resolved_type = rt
1122
+ break
1123
+ except Exception as e:
1124
+ errors.append(f"{rt}: {str(e)}")
1125
+ if resolved_type is None or detail is None:
1126
+ return (
1127
+ None,
1128
+ {
1129
+ "repo_id": rid,
1130
+ "error": "; ".join(errors[:3]) if errors else "repo lookup failed",
1131
+ "attempted_repo_types": list(attempt_types),
1132
+ "source": last_endpoint,
1133
+ },
1134
+ )
1135
+ return (ctx._normalize_repo_detail_row(detail, resolved_type, rid), None)
1136
+
1137
+
1138
+ async def hf_repo_details(
1139
+ ctx: HelperRuntimeContext,
1140
+ repo_id: str | None = None,
1141
+ repo_ids: list[str] | None = None,
1142
+ repo_type: str = "auto",
1143
+ fields: list[str] | None = None,
1144
+ ) -> dict[str, Any]:
1145
+ start_calls = ctx.call_count["n"]
1146
+ if repo_id is not None and repo_ids is not None:
1147
+ return ctx._helper_error(
1148
+ start_calls=start_calls,
1149
+ source="/api/repos",
1150
+ error="Pass either repo_id or repo_ids, not both",
1151
+ )
1152
+ requested_ids = (
1153
+ [str(repo_id).strip()]
1154
+ if isinstance(repo_id, str) and str(repo_id).strip()
1155
+ else []
1156
+ )
1157
+ if repo_ids is not None:
1158
+ requested_ids = ctx._coerce_str_list(repo_ids)
1159
+ if not requested_ids:
1160
+ return ctx._helper_error(
1161
+ start_calls=start_calls,
1162
+ source="/api/repos",
1163
+ error="repo_id or repo_ids is required",
1164
+ )
1165
+ raw_type = str(repo_type or "auto").strip().lower()
1166
+ if raw_type in {"", "auto"}:
1167
+ base_attempt_types = ["model", "dataset", "space"]
1168
+ else:
1169
+ canonical_type = ctx._canonical_repo_type(raw_type, default="")
1170
+ if canonical_type not in {"model", "dataset", "space"}:
1171
+ return ctx._helper_error(
1172
+ start_calls=start_calls,
1173
+ source="/api/repos",
1174
+ error=f"Unsupported repo_type '{repo_type}'",
1175
+ )
1176
+ base_attempt_types = [canonical_type]
1177
+ api = ctx._get_hf_api_client()
1178
+ items: list[dict[str, Any]] = []
1179
+ failures: list[dict[str, Any]] = []
1180
+ for rid in requested_ids:
1181
+ row, failure = _resolve_repo_detail_row(ctx, api, rid, base_attempt_types)
1182
+ if row is None:
1183
+ if failure is not None:
1184
+ failures.append(failure)
1185
+ continue
1186
+ items.append(row)
1187
+ if not items:
1188
+ summary = failures[0]["error"] if failures else "repo lookup failed"
1189
+ return ctx._helper_error(
1190
+ start_calls=start_calls,
1191
+ source="/api/repos",
1192
+ error=summary,
1193
+ failures=failures,
1194
+ repo_type=repo_type,
1195
+ )
1196
+ try:
1197
+ items = ctx._project_repo_items(items, fields)
1198
+ except ValueError as exc:
1199
+ return ctx._helper_error(start_calls=start_calls, source="/api/repos", error=exc)
1200
+ return ctx._helper_success(
1201
+ start_calls=start_calls,
1202
+ source="/api/repos",
1203
+ items=items,
1204
+ repo_type=repo_type,
1205
+ requested_repo_ids=requested_ids,
1206
+ failures=failures or None,
1207
+ matched=len(items),
1208
+ returned=len(items),
1209
+ )
1210
+
1211
+
1212
+ async def hf_trending(
1213
+ ctx: HelperRuntimeContext,
1214
+ repo_type: str = "model",
1215
+ limit: int = 20,
1216
+ where: dict[str, Any] | None = None,
1217
+ fields: list[str] | None = None,
1218
+ ) -> dict[str, Any]:
1219
+ start_calls = ctx.call_count["n"]
1220
+ default_limit = ctx._policy_int("hf_trending", "default_limit", 20)
1221
+ max_limit = ctx._policy_int(
1222
+ "hf_trending", "max_limit", TRENDING_ENDPOINT_MAX_LIMIT
1223
+ )
1224
+ raw_type = str(repo_type or "model").strip().lower()
1225
+ if raw_type == "all":
1226
+ requested_type = "all"
1227
+ else:
1228
+ requested_type = ctx._canonical_repo_type(raw_type, default="")
1229
+ if requested_type not in {"model", "dataset", "space"}:
1230
+ return ctx._helper_error(
1231
+ start_calls=start_calls,
1232
+ source="/api/trending",
1233
+ error=f"Unsupported repo_type '{repo_type}'",
1234
+ )
1235
+ lim = ctx._clamp_int(limit, default=default_limit, minimum=1, maximum=max_limit)
1236
+ resp = ctx._host_raw_call(
1237
+ "/api/trending", params={"type": requested_type, "limit": lim}
1238
+ )
1239
+ if not resp.get("ok"):
1240
+ return ctx._helper_error(
1241
+ start_calls=start_calls,
1242
+ source="/api/trending",
1243
+ error=resp.get("error") or "trending fetch failed",
1244
+ )
1245
+ payload = resp.get("data") if isinstance(resp.get("data"), dict) else {}
1246
+ rows = (
1247
+ payload.get("recentlyTrending")
1248
+ if isinstance(payload.get("recentlyTrending"), list)
1249
+ else []
1250
+ )
1251
+ items: list[dict[str, Any]] = []
1252
+ default_row_type = requested_type if requested_type != "all" else "model"
1253
+ for idx, row in enumerate(rows[:lim], start=1):
1254
+ if not isinstance(row, dict):
1255
+ continue
1256
+ repo = row.get("repoData") if isinstance(row.get("repoData"), dict) else {}
1257
+ items.append(ctx._normalize_trending_row(repo, default_row_type, rank=idx))
1258
+ try:
1259
+ items = ctx._apply_where(items, where, allowed_fields=TRENDING_DEFAULT_FIELDS)
1260
+ except ValueError as exc:
1261
+ return ctx._helper_error(
1262
+ start_calls=start_calls,
1263
+ source="/api/trending",
1264
+ error=exc,
1265
+ )
1266
+ matched = len(items)
1267
+ try:
1268
+ items = ctx._project_items(
1269
+ items[:lim],
1270
+ fields,
1271
+ allowed_fields=TRENDING_DEFAULT_FIELDS,
1272
+ )
1273
+ except ValueError as exc:
1274
+ return ctx._helper_error(
1275
+ start_calls=start_calls,
1276
+ source="/api/trending",
1277
+ error=exc,
1278
+ )
1279
+ return ctx._helper_success(
1280
+ start_calls=start_calls,
1281
+ source="/api/trending",
1282
+ items=items,
1283
+ repo_type=requested_type,
1284
+ limit=lim,
1285
+ scanned=len(rows),
1286
+ matched=matched,
1287
+ returned=len(items),
1288
+ trending_score_available=any(
1289
+ (item.get("trending_score") is not None for item in items)
1290
+ ),
1291
+ ordered_ranking=True,
1292
+ )
1293
+
1294
+
1295
+ async def hf_daily_papers(
1296
+ ctx: HelperRuntimeContext,
1297
+ limit: int = 20,
1298
+ where: dict[str, Any] | None = None,
1299
+ fields: list[str] | None = None,
1300
+ ) -> dict[str, Any]:
1301
+ start_calls = ctx.call_count["n"]
1302
+ default_limit = ctx._policy_int("hf_daily_papers", "default_limit", 20)
1303
+ max_limit = ctx._policy_int(
1304
+ "hf_daily_papers", "max_limit", OUTPUT_ITEMS_TRUNCATION_LIMIT
1305
+ )
1306
+ lim = ctx._clamp_int(limit, default=default_limit, minimum=1, maximum=max_limit)
1307
+ resp = ctx._host_raw_call("/api/daily_papers", params={"limit": lim})
1308
+ if not resp.get("ok"):
1309
+ return ctx._helper_error(
1310
+ start_calls=start_calls,
1311
+ source="/api/daily_papers",
1312
+ error=resp.get("error") or "daily papers fetch failed",
1313
+ )
1314
+ payload = resp.get("data") if isinstance(resp.get("data"), list) else []
1315
+ items: list[dict[str, Any]] = []
1316
+ for idx, row in enumerate(payload[:lim], start=1):
1317
+ if not isinstance(row, dict):
1318
+ continue
1319
+ items.append(ctx._normalize_daily_paper_row(row, rank=idx))
1320
+ try:
1321
+ items = ctx._apply_where(
1322
+ items, where, allowed_fields=DAILY_PAPER_CANONICAL_FIELDS
1323
+ )
1324
+ except ValueError as exc:
1325
+ return ctx._helper_error(
1326
+ start_calls=start_calls,
1327
+ source="/api/daily_papers",
1328
+ error=exc,
1329
+ )
1330
+ matched = len(items)
1331
+ try:
1332
+ items = ctx._project_daily_paper_items(items[:lim], fields)
1333
+ except ValueError as exc:
1334
+ return ctx._helper_error(
1335
+ start_calls=start_calls,
1336
+ source="/api/daily_papers",
1337
+ error=exc,
1338
+ )
1339
+ return ctx._helper_success(
1340
+ start_calls=start_calls,
1341
+ source="/api/daily_papers",
1342
+ items=items,
1343
+ limit=lim,
1344
+ scanned=len(payload),
1345
+ matched=matched,
1346
+ returned=len(items),
1347
+ ordered_ranking=True,
1348
+ )
1349
+
1350
+
1351
+ def register_repo_helpers(ctx: HelperRuntimeContext) -> dict[str, Callable[..., Any]]:
1352
+ return {
1353
+ "hf_models_search": partial(hf_models_search, ctx),
1354
+ "hf_datasets_search": partial(hf_datasets_search, ctx),
1355
+ "hf_spaces_search": partial(hf_spaces_search, ctx),
1356
+ "hf_repo_search": partial(hf_repo_search, ctx),
1357
+ "hf_user_likes": partial(hf_user_likes, ctx),
1358
+ "hf_repo_likers": partial(hf_repo_likers, ctx),
1359
+ "hf_repo_discussions": partial(hf_repo_discussions, ctx),
1360
+ "hf_repo_discussion_details": partial(hf_repo_discussion_details, ctx),
1361
+ "hf_repo_details": partial(hf_repo_details, ctx),
1362
+ "hf_trending": partial(hf_trending, ctx),
1363
+ "hf_daily_papers": partial(hf_daily_papers, ctx),
1364
+ }
monty_api/http_runtime.py ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import TYPE_CHECKING, Any
6
+ from urllib.error import HTTPError, URLError
7
+ from urllib.parse import urlencode
8
+ from urllib.request import Request, urlopen
9
+
10
+ from .aliases import REPO_SORT_KEYS
11
+ from .constants import (
12
+ DEFAULT_TIMEOUT_SEC,
13
+ )
14
+ from .registry import REPO_API_ADAPTERS, REPO_SEARCH_DEFAULT_EXPAND
15
+ from .validation import _endpoint_allowed, _normalize_endpoint, _sanitize_params
16
+
17
+ if TYPE_CHECKING:
18
+ from huggingface_hub import HfApi
19
+
20
+
21
+ def _load_request_token() -> str | None:
22
+ try:
23
+ from fast_agent.mcp.auth.context import request_bearer_token # type: ignore
24
+
25
+ token = request_bearer_token.get()
26
+ if token:
27
+ return token
28
+ except Exception:
29
+ pass
30
+ return None
31
+
32
+
33
+ def _load_token() -> str | None:
34
+ token = _load_request_token()
35
+ if token:
36
+ return token
37
+ return os.getenv("HF_TOKEN") or None
38
+
39
+
40
+ def _json_best_effort(raw: bytes) -> Any:
41
+ try:
42
+ return json.loads(raw)
43
+ except Exception:
44
+ return raw.decode("utf-8", errors="replace")
45
+
46
+
47
+ def _clamp_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
48
+ try:
49
+ out = int(value)
50
+ except Exception:
51
+ out = default
52
+ return max(minimum, min(out, maximum))
53
+
54
+
55
+ def _as_int(value: Any) -> int | None:
56
+ try:
57
+ return int(value)
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _canonical_repo_type(value: Any, *, default: str = "model") -> str:
63
+ raw = str(value or "").strip().lower()
64
+ aliases = {
65
+ "model": "model",
66
+ "models": "model",
67
+ "dataset": "dataset",
68
+ "datasets": "dataset",
69
+ "space": "space",
70
+ "spaces": "space",
71
+ }
72
+ return aliases.get(raw, default)
73
+
74
+
75
+ def _normalize_repo_sort_key(
76
+ repo_type: str, sort_value: Any
77
+ ) -> tuple[str | None, str | None]:
78
+ raw = str(sort_value or "").strip()
79
+ if not raw:
80
+ return None, None
81
+
82
+ key = raw
83
+ if key not in {
84
+ "created_at",
85
+ "downloads",
86
+ "last_modified",
87
+ "likes",
88
+ "trending_score",
89
+ }:
90
+ return None, f"Invalid sort key '{raw}'"
91
+
92
+ rt = _canonical_repo_type(repo_type)
93
+ allowed = REPO_SORT_KEYS.get(rt, set())
94
+ if key not in allowed:
95
+ return (
96
+ None,
97
+ f"Invalid sort key '{raw}' for repo_type='{rt}'. Allowed: {', '.join(sorted(allowed))}",
98
+ )
99
+ return key, None
100
+
101
+
102
+ def _repo_api_adapter(repo_type: str) -> Any:
103
+ rt = _canonical_repo_type(repo_type, default="")
104
+ adapter = REPO_API_ADAPTERS.get(rt)
105
+ if adapter is None:
106
+ raise ValueError(f"Unsupported repo_type '{repo_type}'")
107
+ return adapter
108
+
109
+
110
+ def _repo_list_call(api: HfApi, repo_type: str, **kwargs: Any) -> list[Any]:
111
+ adapter = _repo_api_adapter(repo_type)
112
+ method = getattr(api, adapter.list_method_name)
113
+ return list(method(**kwargs))
114
+
115
+
116
+ def _repo_detail_call(api: HfApi, repo_type: str, repo_id: str) -> Any:
117
+ adapter = _repo_api_adapter(repo_type)
118
+ method = getattr(api, adapter.detail_method_name)
119
+ if _canonical_repo_type(repo_type) == "space":
120
+ return method(repo_id, expand=list(REPO_SEARCH_DEFAULT_EXPAND["space"]))
121
+ return method(repo_id)
122
+
123
+
124
+ def _coerce_str_list(value: Any) -> list[str]:
125
+ if value is None:
126
+ return []
127
+ if isinstance(value, str):
128
+ raw = [value]
129
+ elif isinstance(value, (list, tuple, set)):
130
+ raw = list(value)
131
+ else:
132
+ raise ValueError("Expected a string or list of strings")
133
+ return [str(v).strip() for v in raw if str(v).strip()]
134
+
135
+
136
+ def _optional_str_list(value: Any) -> list[str] | None:
137
+ if value is None:
138
+ return None
139
+ if isinstance(value, str):
140
+ out = [value.strip()] if value.strip() else []
141
+ return out or None
142
+ if isinstance(value, (list, tuple, set)):
143
+ out = [str(v).strip() for v in value if str(v).strip()]
144
+ return out or None
145
+ return None
146
+
147
+
148
+ def _space_runtime_to_dict(value: Any) -> dict[str, Any] | None:
149
+ if value is None:
150
+ return None
151
+
152
+ if isinstance(value, dict):
153
+ raw = value
154
+ hardware = raw.get("hardware")
155
+ current_hardware = (
156
+ hardware.get("current") if isinstance(hardware, dict) else hardware
157
+ )
158
+ requested_hardware = (
159
+ hardware.get("requested")
160
+ if isinstance(hardware, dict)
161
+ else raw.get("requested_hardware") or raw.get("requestedHardware")
162
+ )
163
+ sleep_time = _as_int(
164
+ raw.get("gcTimeout")
165
+ if raw.get("gcTimeout") is not None
166
+ else raw.get("sleep_time") or raw.get("sleepTime")
167
+ )
168
+ out = {
169
+ "stage": raw.get("stage"),
170
+ "hardware": current_hardware,
171
+ "requested_hardware": requested_hardware,
172
+ "sleep_time": sleep_time,
173
+ }
174
+ return {key: val for key, val in out.items() if val is not None} or None
175
+
176
+ out = {
177
+ "stage": getattr(value, "stage", None),
178
+ "hardware": getattr(value, "hardware", None),
179
+ "requested_hardware": getattr(value, "requested_hardware", None),
180
+ "sleep_time": _as_int(getattr(value, "sleep_time", None)),
181
+ }
182
+ return {key: val for key, val in out.items() if val is not None} or None
183
+
184
+
185
+ def _extract_num_params(num_params: Any = None, safetensors: Any = None) -> int | None:
186
+ direct = _as_int(num_params)
187
+ if direct is not None:
188
+ return direct
189
+
190
+ total = getattr(safetensors, "total", None)
191
+ if total is None and isinstance(safetensors, dict):
192
+ total = safetensors.get("total")
193
+ return _as_int(total)
194
+
195
+
196
+ def _extract_num_params_from_object(row: Any) -> int | None:
197
+ raw_num_params = getattr(row, "num_params", None)
198
+ if raw_num_params is None:
199
+ raw_num_params = getattr(row, "numParameters", None)
200
+ if raw_num_params is None:
201
+ raw_num_params = getattr(row, "num_parameters", None)
202
+ return _extract_num_params(raw_num_params, getattr(row, "safetensors", None))
203
+
204
+
205
+ def _extract_num_params_from_dict(row: dict[str, Any]) -> int | None:
206
+ raw_num_params = row.get("num_params")
207
+ if raw_num_params is None:
208
+ raw_num_params = row.get("numParameters")
209
+ if raw_num_params is None:
210
+ raw_num_params = row.get("num_parameters")
211
+ return _extract_num_params(raw_num_params, row.get("safetensors"))
212
+
213
+
214
+ def _extract_author_names(value: Any) -> list[str] | None:
215
+ if not isinstance(value, (list, tuple)):
216
+ return None
217
+ names: list[str] = []
218
+ for item in value:
219
+ if isinstance(item, str) and item.strip():
220
+ names.append(item.strip())
221
+ continue
222
+ if isinstance(item, dict):
223
+ name = item.get("name")
224
+ if isinstance(name, str) and name.strip():
225
+ names.append(name.strip())
226
+ continue
227
+ name = getattr(item, "name", None)
228
+ if isinstance(name, str) and name.strip():
229
+ names.append(name.strip())
230
+ return names or None
231
+
232
+
233
+ def _extract_profile_name(value: Any) -> str | None:
234
+ if isinstance(value, str) and value.strip():
235
+ return value.strip()
236
+ if isinstance(value, dict):
237
+ for key in ("user", "name", "fullname", "handle"):
238
+ candidate = value.get(key)
239
+ if isinstance(candidate, str) and candidate.strip():
240
+ return candidate.strip()
241
+ return None
242
+ for attr in ("user", "name", "fullname", "handle"):
243
+ candidate = getattr(value, attr, None)
244
+ if isinstance(candidate, str) and candidate.strip():
245
+ return candidate.strip()
246
+ return None
247
+
248
+
249
+ def _author_from_any(value: Any) -> str | None:
250
+ if isinstance(value, str) and value:
251
+ return value
252
+ if isinstance(value, dict):
253
+ for key in ("name", "username", "user", "login"):
254
+ candidate = value.get(key)
255
+ if isinstance(candidate, str) and candidate:
256
+ return candidate
257
+ return None
258
+
259
+
260
+ def _dt_to_str(value: Any) -> str | None:
261
+ if value is None:
262
+ return None
263
+ iso = getattr(value, "isoformat", None)
264
+ if callable(iso):
265
+ try:
266
+ return str(iso())
267
+ except Exception:
268
+ pass
269
+ return str(value)
270
+
271
+
272
+ def _repo_web_url(repo_type: str, repo_id: str | None) -> str | None:
273
+ if not isinstance(repo_id, str) or not repo_id:
274
+ return None
275
+ base = os.getenv("HF_ENDPOINT", "https://huggingface.co").rstrip("/")
276
+ rt = _canonical_repo_type(repo_type, default="")
277
+ if rt == "dataset":
278
+ return f"{base}/datasets/{repo_id}"
279
+ if rt == "space":
280
+ return f"{base}/spaces/{repo_id}"
281
+ return f"{base}/{repo_id}"
282
+
283
+
284
+ def _build_repo_row(
285
+ *,
286
+ repo_id: Any,
287
+ repo_type: str,
288
+ author: Any = None,
289
+ likes: Any = None,
290
+ downloads: Any = None,
291
+ created_at: Any = None,
292
+ last_modified: Any = None,
293
+ pipeline_tag: Any = None,
294
+ num_params: Any = None,
295
+ private: Any = None,
296
+ trending_score: Any = None,
297
+ tags: Any = None,
298
+ sha: Any = None,
299
+ gated: Any = None,
300
+ library_name: Any = None,
301
+ description: Any = None,
302
+ paperswithcode_id: Any = None,
303
+ sdk: Any = None,
304
+ models: Any = None,
305
+ datasets: Any = None,
306
+ subdomain: Any = None,
307
+ runtime: Any = None,
308
+ runtime_stage: Any = None,
309
+ ) -> dict[str, Any]:
310
+ rt = _canonical_repo_type(repo_type)
311
+ author_value = author
312
+ if (
313
+ not isinstance(author_value, str)
314
+ and isinstance(repo_id, str)
315
+ and "/" in repo_id
316
+ ):
317
+ author_value = repo_id.split("/", 1)[0]
318
+
319
+ runtime_payload = _space_runtime_to_dict(runtime)
320
+ resolved_runtime_stage = (
321
+ runtime_stage
322
+ if runtime_stage is not None
323
+ else runtime_payload.get("stage")
324
+ if isinstance(runtime_payload, dict)
325
+ else None
326
+ )
327
+
328
+ return {
329
+ "id": repo_id,
330
+ "slug": repo_id,
331
+ "repo_id": repo_id,
332
+ "repo_type": rt,
333
+ "author": author_value,
334
+ "likes": _as_int(likes),
335
+ "downloads": _as_int(downloads),
336
+ "created_at": _dt_to_str(created_at),
337
+ "last_modified": _dt_to_str(last_modified),
338
+ "pipeline_tag": pipeline_tag,
339
+ "num_params": _as_int(num_params),
340
+ "private": private,
341
+ "trending_score": _as_int(trending_score)
342
+ if trending_score is not None
343
+ else None,
344
+ "repo_url": _repo_web_url(rt, repo_id if isinstance(repo_id, str) else None),
345
+ "tags": _optional_str_list(tags),
346
+ "sha": sha,
347
+ "gated": gated,
348
+ "library_name": library_name,
349
+ "description": description,
350
+ "paperswithcode_id": paperswithcode_id,
351
+ "sdk": sdk,
352
+ "models": _optional_str_list(models),
353
+ "datasets": _optional_str_list(datasets),
354
+ "subdomain": subdomain,
355
+ "runtime_stage": resolved_runtime_stage,
356
+ "runtime": runtime_payload,
357
+ }
358
+
359
+
360
+ def _normalize_repo_search_row(row: Any, repo_type: str) -> dict[str, Any]:
361
+ return _build_repo_row(
362
+ repo_id=getattr(row, "id", None),
363
+ repo_type=repo_type,
364
+ author=getattr(row, "author", None),
365
+ likes=getattr(row, "likes", None),
366
+ downloads=getattr(row, "downloads", None),
367
+ created_at=getattr(row, "created_at", None),
368
+ last_modified=getattr(row, "last_modified", None),
369
+ pipeline_tag=getattr(row, "pipeline_tag", None),
370
+ num_params=_extract_num_params_from_object(row),
371
+ private=getattr(row, "private", None),
372
+ trending_score=getattr(row, "trending_score", None),
373
+ tags=getattr(row, "tags", None),
374
+ sha=getattr(row, "sha", None),
375
+ gated=getattr(row, "gated", None),
376
+ library_name=getattr(row, "library_name", None),
377
+ description=getattr(row, "description", None),
378
+ paperswithcode_id=getattr(row, "paperswithcode_id", None),
379
+ sdk=getattr(row, "sdk", None),
380
+ models=getattr(row, "models", None),
381
+ datasets=getattr(row, "datasets", None),
382
+ subdomain=getattr(row, "subdomain", None),
383
+ runtime=getattr(row, "runtime", None),
384
+ )
385
+
386
+
387
+ def _normalize_repo_detail_row(
388
+ detail: Any, repo_type: str, repo_id: str
389
+ ) -> dict[str, Any]:
390
+ row = _normalize_repo_search_row(detail, repo_type)
391
+ resolved_repo_id = row.get("repo_id") or repo_id
392
+ row["id"] = row.get("id") or resolved_repo_id
393
+ row["slug"] = row.get("slug") or resolved_repo_id
394
+ row["repo_id"] = resolved_repo_id
395
+ row["repo_url"] = _repo_web_url(repo_type, resolved_repo_id)
396
+ return row
397
+
398
+
399
+ def _normalize_trending_row(
400
+ repo: dict[str, Any], default_repo_type: str, rank: int | None = None
401
+ ) -> dict[str, Any]:
402
+ row = _build_repo_row(
403
+ repo_id=repo.get("id"),
404
+ repo_type=repo.get("type") or repo.get("repoType") or default_repo_type,
405
+ author=repo.get("author"),
406
+ likes=repo.get("likes"),
407
+ downloads=repo.get("downloads"),
408
+ created_at=repo.get("createdAt"),
409
+ last_modified=repo.get("lastModified"),
410
+ pipeline_tag=repo.get("pipeline_tag"),
411
+ num_params=_extract_num_params_from_dict(repo),
412
+ private=repo.get("private"),
413
+ trending_score=repo.get("trendingScore"),
414
+ tags=repo.get("tags"),
415
+ sha=repo.get("sha"),
416
+ gated=repo.get("gated"),
417
+ library_name=repo.get("library_name"),
418
+ description=repo.get("description"),
419
+ paperswithcode_id=repo.get("paperswithcode_id"),
420
+ sdk=repo.get("sdk"),
421
+ models=repo.get("models"),
422
+ datasets=repo.get("datasets"),
423
+ subdomain=repo.get("subdomain"),
424
+ runtime=repo.get("runtime"),
425
+ runtime_stage=repo.get("runtime_stage") or repo.get("runtimeStage"),
426
+ )
427
+ if rank is not None:
428
+ row["trending_rank"] = rank
429
+ return row
430
+
431
+
432
+ def _normalize_daily_paper_row(
433
+ row: dict[str, Any], rank: int | None = None
434
+ ) -> dict[str, Any]:
435
+ paper = row.get("paper") if isinstance(row.get("paper"), dict) else {}
436
+ org = (
437
+ row.get("organization")
438
+ if isinstance(row.get("organization"), dict)
439
+ else paper.get("organization")
440
+ )
441
+ organization = None
442
+ if isinstance(org, dict):
443
+ organization = org.get("name") or org.get("fullname")
444
+
445
+ item = {
446
+ "paper_id": paper.get("id"),
447
+ "title": row.get("title") or paper.get("title"),
448
+ "summary": row.get("summary")
449
+ or paper.get("summary")
450
+ or paper.get("ai_summary"),
451
+ "published_at": row.get("publishedAt") or paper.get("publishedAt"),
452
+ "submitted_on_daily_at": paper.get("submittedOnDailyAt"),
453
+ "authors": _extract_author_names(paper.get("authors")),
454
+ "organization": organization,
455
+ "submitted_by": _extract_profile_name(
456
+ row.get("submittedBy") or paper.get("submittedOnDailyBy")
457
+ ),
458
+ "discussion_id": paper.get("discussionId"),
459
+ "upvotes": _as_int(paper.get("upvotes")),
460
+ "github_repo_url": paper.get("githubRepo"),
461
+ "github_stars": _as_int(paper.get("githubStars")),
462
+ "project_page_url": paper.get("projectPage"),
463
+ "num_comments": _as_int(row.get("numComments")),
464
+ "is_author_participating": row.get("isAuthorParticipating")
465
+ if isinstance(row.get("isAuthorParticipating"), bool)
466
+ else None,
467
+ "repo_id": row.get("repo_id") or paper.get("repo_id"),
468
+ "rank": rank,
469
+ }
470
+ return item
471
+
472
+
473
+ def _normalize_collection_repo_item(row: dict[str, Any]) -> dict[str, Any] | None:
474
+ repo_id = row.get("id") or row.get("repoId") or row.get("repo_id")
475
+ if not isinstance(repo_id, str) or not repo_id:
476
+ return None
477
+
478
+ repo_type = _canonical_repo_type(
479
+ row.get("repoType") or row.get("repo_type") or row.get("type"), default=""
480
+ )
481
+ if repo_type not in {"model", "dataset", "space"}:
482
+ return None
483
+
484
+ return _build_repo_row(
485
+ repo_id=repo_id,
486
+ repo_type=repo_type,
487
+ author=row.get("author") or _author_from_any(row.get("authorData")),
488
+ likes=row.get("likes"),
489
+ downloads=row.get("downloads"),
490
+ created_at=row.get("createdAt") or row.get("created_at"),
491
+ last_modified=row.get("lastModified") or row.get("last_modified"),
492
+ pipeline_tag=row.get("pipeline_tag") or row.get("pipelineTag"),
493
+ num_params=_extract_num_params_from_dict(row),
494
+ private=row.get("private"),
495
+ tags=row.get("tags"),
496
+ gated=row.get("gated"),
497
+ library_name=row.get("library_name") or row.get("libraryName"),
498
+ description=row.get("description"),
499
+ paperswithcode_id=row.get("paperswithcode_id") or row.get("paperswithcodeId"),
500
+ sdk=row.get("sdk"),
501
+ models=row.get("models"),
502
+ datasets=row.get("datasets"),
503
+ subdomain=row.get("subdomain"),
504
+ runtime=row.get("runtime"),
505
+ runtime_stage=row.get("runtime_stage") or row.get("runtimeStage"),
506
+ )
507
+
508
+
509
+ def _sort_repo_rows(
510
+ rows: list[dict[str, Any]], sort_key: str | None
511
+ ) -> list[dict[str, Any]]:
512
+ if not sort_key:
513
+ return rows
514
+
515
+ if sort_key in {"likes", "downloads", "trending_score"}:
516
+ return sorted(
517
+ rows, key=lambda row: _as_int(row.get(sort_key)) or -1, reverse=True
518
+ )
519
+
520
+ if sort_key in {"created_at", "last_modified"}:
521
+ return sorted(rows, key=lambda row: str(row.get(sort_key) or ""), reverse=True)
522
+
523
+ return rows
524
+
525
+
526
+ def call_api_host(
527
+ endpoint: str,
528
+ *,
529
+ method: str = "GET",
530
+ params: dict[str, Any] | None = None,
531
+ json_body: dict[str, Any] | None = None,
532
+ timeout_sec: int = DEFAULT_TIMEOUT_SEC,
533
+ strict_mode: bool = False,
534
+ ) -> dict[str, Any]:
535
+ method_u = method.upper().strip()
536
+ if method_u not in {"GET", "POST"}:
537
+ raise ValueError("Only GET and POST are supported")
538
+
539
+ ep = _normalize_endpoint(endpoint)
540
+ if not _endpoint_allowed(ep, strict_mode):
541
+ raise ValueError(f"Endpoint not allowed: {ep}")
542
+
543
+ params = _sanitize_params(ep, params)
544
+ if ep == "/api/recent-activity":
545
+ feed_type = str((params or {}).get("feedType", "")).strip().lower()
546
+ if feed_type not in {"user", "org"}:
547
+ raise ValueError("/api/recent-activity requires feedType=user|org")
548
+ if not str((params or {}).get("entity", "")).strip():
549
+ raise ValueError("/api/recent-activity requires entity")
550
+
551
+ base = os.getenv("HF_ENDPOINT", "https://huggingface.co").rstrip("/")
552
+ q = urlencode(params or {}, doseq=True)
553
+ url = f"{base}{ep}" + (f"?{q}" if q else "")
554
+
555
+ headers = {"Accept": "application/json"}
556
+ token = _load_token()
557
+ if token:
558
+ headers["Authorization"] = f"Bearer {token}"
559
+
560
+ data = None
561
+ if method_u == "POST":
562
+ headers["Content-Type"] = "application/json"
563
+ data = json.dumps(json_body or {}).encode("utf-8")
564
+
565
+ req = Request(url, method=method_u, headers=headers, data=data)
566
+ try:
567
+ with urlopen(req, timeout=timeout_sec) as res:
568
+ payload = _json_best_effort(res.read())
569
+ return {
570
+ "ok": True,
571
+ "status": int(res.status),
572
+ "url": url,
573
+ "data": payload,
574
+ "error": None,
575
+ }
576
+ except HTTPError as e:
577
+ payload = _json_best_effort(e.read())
578
+ err = (
579
+ payload
580
+ if isinstance(payload, str)
581
+ else json.dumps(payload, ensure_ascii=False)[:1000]
582
+ )
583
+ return {
584
+ "ok": False,
585
+ "status": int(e.code),
586
+ "url": url,
587
+ "data": payload,
588
+ "error": err,
589
+ }
590
+ except URLError as e:
591
+ return {
592
+ "ok": False,
593
+ "status": 0,
594
+ "url": url,
595
+ "data": None,
596
+ "error": f"Network error: {e}",
597
+ }
monty_api/llm_time_hook.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING
5
+
6
+ from fast_agent.constants import FAST_AGENT_TIMING
7
+ from fast_agent.hooks import show_hook_message
8
+ from fast_agent.mcp.helpers.content_helpers import get_text
9
+
10
+ if TYPE_CHECKING:
11
+ from fast_agent.hooks import HookContext
12
+
13
+
14
+ def _timing_payload(ctx: "HookContext") -> dict[str, object] | None:
15
+ channels = ctx.message.channels or {}
16
+ timing_blocks = channels.get(FAST_AGENT_TIMING, [])
17
+ if not timing_blocks:
18
+ return None
19
+
20
+ payload_text = get_text(timing_blocks[0])
21
+ if not payload_text:
22
+ return None
23
+
24
+ try:
25
+ payload = json.loads(payload_text)
26
+ except json.JSONDecodeError:
27
+ return None
28
+
29
+ return payload if isinstance(payload, dict) else None
30
+
31
+
32
+ def _coerce_float(value: object) -> float | None:
33
+ if isinstance(value, bool):
34
+ return None
35
+ if isinstance(value, int | float):
36
+ return float(value)
37
+ return None
38
+
39
+
40
+ def _format_duration_ms(duration_ms: float) -> str:
41
+ if duration_ms >= 1000:
42
+ return f"{duration_ms / 1000:.2f}s"
43
+ return f"{duration_ms:.0f}ms"
44
+
45
+
46
+ async def display_llm_time(ctx: "HookContext") -> None:
47
+ payload = _timing_payload(ctx)
48
+ if payload is None:
49
+ return
50
+
51
+ duration_ms = _coerce_float(payload.get("duration_ms"))
52
+ if duration_ms is None:
53
+ return
54
+
55
+ show_hook_message(
56
+ ctx,
57
+ _format_duration_ms(duration_ms),
58
+ hook_name="llm_time",
59
+ hook_kind="tool",
60
+ )
monty_api/query_entrypoints.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import inspect
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from typing import Any, Callable
11
+
12
+ from .constants import (
13
+ DEFAULT_MAX_CALLS,
14
+ DEFAULT_MONTY_MAX_ALLOCATIONS,
15
+ DEFAULT_MONTY_MAX_MEMORY,
16
+ DEFAULT_MONTY_MAX_RECURSION_DEPTH,
17
+ DEFAULT_TIMEOUT_SEC,
18
+ INTERNAL_STRICT_MODE,
19
+ MAX_CALLS_LIMIT,
20
+ )
21
+ from .runtime_context import build_runtime_helper_environment
22
+ from .validation import (
23
+ _coerce_jsonish_python_literals,
24
+ _compact_result_metadata,
25
+ _summarize_limit_hit,
26
+ _truncate_result_payload,
27
+ _validate_generated_code,
28
+ _wrap_raw_result,
29
+ )
30
+
31
+
32
+ class MontyExecutionError(RuntimeError):
33
+ def __init__(self, message: str, api_calls: int, trace: list[dict[str, Any]]):
34
+ super().__init__(message)
35
+ self.api_calls = api_calls
36
+ self.trace = trace
37
+
38
+
39
+ def _query_debug_enabled() -> bool:
40
+ value = os.environ.get("MONTY_DEBUG_QUERY", "")
41
+ return value.strip().lower() in {"1", "true", "yes", "on"}
42
+
43
+
44
+ def _execution_debug_enabled() -> bool:
45
+ value = os.environ.get("MONTY_DEBUG_EXECUTION", "")
46
+ if value.strip().lower() in {"1", "true", "yes", "on"}:
47
+ return True
48
+ return _query_debug_enabled()
49
+
50
+
51
+ def _debug_log(*parts: Any) -> None:
52
+ if not _execution_debug_enabled():
53
+ return
54
+ print("[monty-debug]", *parts, file=sys.stderr)
55
+ sys.stderr.flush()
56
+
57
+
58
+ def _log_generated_query(
59
+ *, query: str | None, code: str, max_calls: int | None, timeout_sec: int | None
60
+ ) -> None:
61
+ if not _query_debug_enabled():
62
+ return
63
+ if query:
64
+ print("[monty-debug] query:", file=sys.stderr)
65
+ print(query, file=sys.stderr)
66
+ print("[monty-debug] max_calls:", max_calls, file=sys.stderr)
67
+ print("[monty-debug] timeout_sec:", timeout_sec, file=sys.stderr)
68
+ print("[monty-debug] code:", file=sys.stderr)
69
+ print(code, file=sys.stderr)
70
+ sys.stderr.flush()
71
+
72
+
73
+ def _introspect_helper_signatures() -> dict[str, set[str]]:
74
+ env = build_runtime_helper_environment(
75
+ max_calls=DEFAULT_MAX_CALLS,
76
+ strict_mode=INTERNAL_STRICT_MODE,
77
+ timeout_sec=DEFAULT_TIMEOUT_SEC,
78
+ )
79
+ signatures = {
80
+ name: {
81
+ parameter.name for parameter in inspect.signature(fn).parameters.values()
82
+ }
83
+ for name, fn in env.helper_functions.items()
84
+ }
85
+ return signatures
86
+
87
+
88
+ async def _run_with_monty(
89
+ *,
90
+ code: str,
91
+ query: str | None,
92
+ max_calls: int,
93
+ strict_mode: bool,
94
+ timeout_sec: int,
95
+ ) -> dict[str, Any]:
96
+ _debug_log(
97
+ "run_monty:start",
98
+ f"max_calls={max_calls}",
99
+ f"timeout_sec={timeout_sec}",
100
+ f"strict_mode={strict_mode}",
101
+ )
102
+ try:
103
+ import pydantic_monty
104
+ except Exception as e:
105
+ raise RuntimeError(
106
+ "pydantic_monty is not installed. Install with `uv pip install pydantic-monty`."
107
+ ) from e
108
+
109
+ env = build_runtime_helper_environment(
110
+ max_calls=max_calls,
111
+ strict_mode=strict_mode,
112
+ timeout_sec=timeout_sec,
113
+ )
114
+
115
+ m = pydantic_monty.Monty(
116
+ code,
117
+ inputs=["query", "max_calls"],
118
+ script_name="monty_agent.py",
119
+ type_check=False,
120
+ )
121
+
122
+ def _collecting_wrapper(
123
+ helper_name: str, fn: Callable[..., Any]
124
+ ) -> Callable[..., Any]:
125
+ async def wrapped(*args: Any, **kwargs: Any) -> Any:
126
+ started = time.perf_counter()
127
+ _debug_log(
128
+ "helper:start",
129
+ helper_name,
130
+ f"args={len(args)}",
131
+ f"kwargs={sorted(kwargs)}",
132
+ )
133
+ result = await fn(*args, **kwargs)
134
+ summary = _summarize_limit_hit(helper_name, result)
135
+ if summary is not None and len(env.limit_summaries) < 20:
136
+ env.limit_summaries.append(summary)
137
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
138
+ _debug_log(
139
+ "helper:end",
140
+ helper_name,
141
+ f"elapsed_ms={elapsed_ms}",
142
+ f"api_calls={env.call_count['n']}",
143
+ )
144
+ return result
145
+
146
+ return wrapped
147
+
148
+ limits: pydantic_monty.ResourceLimits = {
149
+ "max_duration_secs": float(timeout_sec),
150
+ "max_memory": DEFAULT_MONTY_MAX_MEMORY,
151
+ "max_allocations": DEFAULT_MONTY_MAX_ALLOCATIONS,
152
+ "max_recursion_depth": DEFAULT_MONTY_MAX_RECURSION_DEPTH,
153
+ }
154
+
155
+ try:
156
+ _debug_log("run_monty:invoke")
157
+ result = await pydantic_monty.run_monty_async(
158
+ m,
159
+ inputs={"query": query or "", "max_calls": max_calls},
160
+ external_functions={
161
+ name: _collecting_wrapper(name, fn)
162
+ for name, fn in env.helper_functions.items()
163
+ },
164
+ limits=limits,
165
+ )
166
+ _debug_log("run_monty:return", f"api_calls={env.call_count['n']}")
167
+ except Exception as e:
168
+ _debug_log("run_monty:error", type(e).__name__, str(e))
169
+ raise MontyExecutionError(str(e), env.call_count["n"], env.trace) from e
170
+
171
+ if env.call_count["n"] == 0:
172
+ if env.internal_helper_used["used"]:
173
+ return {
174
+ "output": _truncate_result_payload(result),
175
+ "api_calls": env.call_count["n"],
176
+ "trace": env.trace,
177
+ "limit_summaries": env.limit_summaries,
178
+ }
179
+ if isinstance(result, dict) and result.get("ok") is True:
180
+ meta = result.get("meta") if isinstance(result.get("meta"), dict) else {}
181
+ source = meta.get("source")
182
+ if isinstance(source, str) and source.startswith("internal://"):
183
+ return {
184
+ "output": _truncate_result_payload(result),
185
+ "api_calls": env.call_count["n"],
186
+ "trace": env.trace,
187
+ "limit_summaries": env.limit_summaries,
188
+ }
189
+ latest_helper_error = env.latest_helper_error_box.get("value")
190
+ if latest_helper_error is not None:
191
+ return {
192
+ "output": _truncate_result_payload(latest_helper_error),
193
+ "api_calls": env.call_count["n"],
194
+ "trace": env.trace,
195
+ "limit_summaries": env.limit_summaries,
196
+ }
197
+ if (
198
+ isinstance(result, dict)
199
+ and result.get("ok") is False
200
+ and isinstance(result.get("error"), str)
201
+ ):
202
+ return {
203
+ "output": _truncate_result_payload(result),
204
+ "api_calls": env.call_count["n"],
205
+ "trace": env.trace,
206
+ "limit_summaries": env.limit_summaries,
207
+ }
208
+ raise MontyExecutionError(
209
+ "Code completed without calling any external API function",
210
+ env.call_count["n"],
211
+ env.trace,
212
+ )
213
+
214
+ if not any(step.get("ok") is True for step in env.trace):
215
+ if (
216
+ isinstance(result, dict)
217
+ and result.get("ok") is False
218
+ and isinstance(result.get("error"), str)
219
+ ):
220
+ return {
221
+ "output": _truncate_result_payload(result),
222
+ "api_calls": env.call_count["n"],
223
+ "trace": env.trace,
224
+ "limit_summaries": env.limit_summaries,
225
+ }
226
+ raise MontyExecutionError(
227
+ "Code completed without a successful API call; refusing non-live fallback result",
228
+ env.call_count["n"],
229
+ env.trace,
230
+ )
231
+
232
+ return {
233
+ "output": _truncate_result_payload(result),
234
+ "api_calls": env.call_count["n"],
235
+ "trace": env.trace,
236
+ "limit_summaries": env.limit_summaries,
237
+ }
238
+
239
+
240
+ def _prepare_query_inputs(
241
+ *,
242
+ query: str | None,
243
+ code: str,
244
+ max_calls: int | None,
245
+ timeout_sec: int | None,
246
+ ) -> tuple[str, str, int, int]:
247
+ if not code or not code.strip():
248
+ raise ValueError("code is required")
249
+
250
+ normalized_query = str(query or "").strip()
251
+ resolved_max_calls = DEFAULT_MAX_CALLS if max_calls is None else max_calls
252
+ resolved_timeout_sec = DEFAULT_TIMEOUT_SEC if timeout_sec is None else timeout_sec
253
+ normalized_max_calls = max(1, min(int(resolved_max_calls), MAX_CALLS_LIMIT))
254
+ normalized_timeout_sec = int(resolved_timeout_sec)
255
+ normalized_code = _coerce_jsonish_python_literals(code.strip())
256
+ _validate_generated_code(normalized_code)
257
+ return normalized_query, normalized_code, normalized_max_calls, normalized_timeout_sec
258
+
259
+
260
+ async def _execute_query(
261
+ *,
262
+ query: str | None,
263
+ code: str,
264
+ max_calls: int | None,
265
+ timeout_sec: int | None,
266
+ ) -> dict[str, Any]:
267
+ _debug_log("execute_query:start")
268
+ prepared_query, prepared_code, prepared_max_calls, prepared_timeout = (
269
+ _prepare_query_inputs(
270
+ query=query,
271
+ code=code,
272
+ max_calls=max_calls,
273
+ timeout_sec=timeout_sec,
274
+ )
275
+ )
276
+ _debug_log(
277
+ "execute_query:prepared",
278
+ f"query_len={len(prepared_query)}",
279
+ f"code_len={len(prepared_code)}",
280
+ f"max_calls={prepared_max_calls}",
281
+ f"timeout_sec={prepared_timeout}",
282
+ )
283
+ _log_generated_query(
284
+ query=prepared_query,
285
+ code=prepared_code,
286
+ max_calls=prepared_max_calls,
287
+ timeout_sec=prepared_timeout,
288
+ )
289
+ return await _run_with_monty(
290
+ code=prepared_code,
291
+ query=prepared_query,
292
+ max_calls=prepared_max_calls,
293
+ strict_mode=INTERNAL_STRICT_MODE,
294
+ timeout_sec=prepared_timeout,
295
+ )
296
+
297
+
298
+ async def hf_hub_query(
299
+ code: str,
300
+ query: str | None = None,
301
+ max_calls: int | None = DEFAULT_MAX_CALLS,
302
+ timeout_sec: int | None = DEFAULT_TIMEOUT_SEC,
303
+ ) -> dict[str, Any]:
304
+ """Use natural-language queries to explore the Hugging Face Hub.
305
+
306
+ Best for read-only Hub discovery, lookup, ranking, and relationship questions
307
+ across users, organizations, repositories, activity, followers, likes,
308
+ discussions, and collections.
309
+ """
310
+ try:
311
+ run = await _execute_query(
312
+ query=query,
313
+ code=code,
314
+ max_calls=max_calls,
315
+ timeout_sec=timeout_sec,
316
+ )
317
+ return {
318
+ "ok": True,
319
+ "data": _compact_result_metadata(run["output"]),
320
+ "error": None,
321
+ "api_calls": run["api_calls"],
322
+ }
323
+ except MontyExecutionError as e:
324
+ return {
325
+ "ok": False,
326
+ "data": None,
327
+ "error": str(e),
328
+ "api_calls": e.api_calls,
329
+ }
330
+ except Exception as e:
331
+ return {
332
+ "ok": False,
333
+ "data": None,
334
+ "error": str(e),
335
+ "api_calls": 0,
336
+ }
337
+
338
+
339
+ async def hf_hub_query_raw(
340
+ code: str,
341
+ query: str | None = None,
342
+ max_calls: int | None = DEFAULT_MAX_CALLS,
343
+ timeout_sec: int | None = DEFAULT_TIMEOUT_SEC,
344
+ ) -> Any:
345
+ """Use natural-language queries to explore the Hugging Face Hub in raw mode.
346
+
347
+ Best for read-only Hub discovery, lookup, ranking, and relationship
348
+ questions when the caller wants a runtime-owned raw envelope:
349
+ ``result`` contains the generated script's final `result` value and ``meta`` contains
350
+ execution details such as timing, call counts, and limit summaries.
351
+ """
352
+ started = time.perf_counter()
353
+ try:
354
+ run = await _execute_query(
355
+ query=query,
356
+ code=code,
357
+ max_calls=max_calls,
358
+ timeout_sec=timeout_sec,
359
+ )
360
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
361
+ return _wrap_raw_result(
362
+ _compact_result_metadata(run["output"]),
363
+ ok=True,
364
+ api_calls=run["api_calls"],
365
+ elapsed_ms=elapsed_ms,
366
+ limit_summaries=run.get("limit_summaries"),
367
+ )
368
+ except MontyExecutionError as e:
369
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
370
+ return _wrap_raw_result(
371
+ None,
372
+ ok=False,
373
+ api_calls=e.api_calls,
374
+ elapsed_ms=elapsed_ms,
375
+ error=str(e),
376
+ )
377
+ except Exception as e:
378
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
379
+ return _wrap_raw_result(
380
+ None,
381
+ ok=False,
382
+ api_calls=0,
383
+ elapsed_ms=elapsed_ms,
384
+ error=str(e),
385
+ )
386
+
387
+
388
+ def _arg_parser() -> argparse.ArgumentParser:
389
+ p = argparse.ArgumentParser(description="Monty-backed API chaining tool (v3)")
390
+ p.add_argument("--query", default=None, help="Optional natural language query/context")
391
+ p.add_argument("--code", default=None, help="Inline Monty code to execute")
392
+ p.add_argument(
393
+ "--code-file", default=None, help="Path to .py file with Monty code to execute"
394
+ )
395
+ p.add_argument(
396
+ "--max-calls",
397
+ type=int,
398
+ default=DEFAULT_MAX_CALLS,
399
+ help="Max external API/helper calls",
400
+ )
401
+ p.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SEC)
402
+ return p
403
+
404
+
405
+ def main() -> int:
406
+ args = _arg_parser().parse_args()
407
+ code = args.code
408
+ if args.code_file:
409
+ with open(args.code_file, "r", encoding="utf-8") as f:
410
+ code = f.read()
411
+
412
+ if not code:
413
+ print(
414
+ json.dumps(
415
+ {"ok": False, "error": "Either --code or --code-file is required"},
416
+ ensure_ascii=False,
417
+ )
418
+ )
419
+ return 1
420
+
421
+ try:
422
+ out = asyncio.run(
423
+ hf_hub_query(
424
+ code=code,
425
+ query=args.query,
426
+ max_calls=args.max_calls,
427
+ timeout_sec=args.timeout,
428
+ )
429
+ )
430
+ print(json.dumps(out, ensure_ascii=False))
431
+ return 0 if out.get("ok") else 1
432
+ except Exception as e:
433
+ print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
434
+ return 1
monty_api/registry.py ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping, NamedTuple
5
+
6
+ from .constants import (
7
+ ACTIVITY_CANONICAL_FIELDS,
8
+ ACTOR_CANONICAL_FIELDS,
9
+ COLLECTION_CANONICAL_FIELDS,
10
+ DAILY_PAPER_CANONICAL_FIELDS,
11
+ DISCUSSION_CANONICAL_FIELDS,
12
+ DISCUSSION_DETAIL_CANONICAL_FIELDS,
13
+ GRAPH_SCAN_LIMIT_CAP,
14
+ LIKES_ENRICHMENT_MAX_REPOS,
15
+ LIKES_RANKING_WINDOW_DEFAULT,
16
+ LIKES_SCAN_LIMIT_CAP,
17
+ OUTPUT_ITEMS_TRUNCATION_LIMIT,
18
+ PROFILE_CANONICAL_FIELDS,
19
+ RECENT_ACTIVITY_PAGE_SIZE,
20
+ RECENT_ACTIVITY_SCAN_MAX_PAGES,
21
+ REPO_CANONICAL_FIELDS,
22
+ TRENDING_ENDPOINT_MAX_LIMIT,
23
+ USER_LIKES_CANONICAL_FIELDS,
24
+ )
25
+
26
+
27
+ class RepoApiAdapter(NamedTuple):
28
+ list_method_name: str
29
+ detail_method_name: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class HelperConfig:
34
+ name: str
35
+ endpoint_patterns: tuple[str, ...] = ()
36
+ default_metadata: Mapping[str, Any] = field(default_factory=dict)
37
+ pagination: Mapping[str, Any] = field(default_factory=dict)
38
+
39
+
40
+ REPO_SEARCH_EXTRA_ARGS: dict[str, set[str]] = {
41
+ "dataset": {
42
+ "benchmark",
43
+ "dataset_name",
44
+ "expand",
45
+ "full",
46
+ "gated",
47
+ "language",
48
+ "language_creators",
49
+ "multilinguality",
50
+ "size_categories",
51
+ "task_categories",
52
+ "task_ids",
53
+ },
54
+ "model": {
55
+ "apps",
56
+ "cardData",
57
+ "emissions_thresholds",
58
+ "expand",
59
+ "fetch_config",
60
+ "full",
61
+ "gated",
62
+ "inference",
63
+ "inference_provider",
64
+ "model_name",
65
+ "num_parameters",
66
+ "pipeline_tag",
67
+ "trained_dataset",
68
+ },
69
+ "space": {"datasets", "expand", "full", "linked", "models"},
70
+ }
71
+
72
+ REPO_SEARCH_DEFAULT_EXPAND: dict[str, list[str]] = {
73
+ "dataset": [
74
+ "author",
75
+ "createdAt",
76
+ "description",
77
+ "downloads",
78
+ "gated",
79
+ "lastModified",
80
+ "likes",
81
+ "paperswithcode_id",
82
+ "private",
83
+ "sha",
84
+ "tags",
85
+ "trendingScore",
86
+ ],
87
+ "model": [
88
+ "author",
89
+ "createdAt",
90
+ "downloads",
91
+ "gated",
92
+ "lastModified",
93
+ "library_name",
94
+ "likes",
95
+ "pipeline_tag",
96
+ "private",
97
+ "safetensors",
98
+ "sha",
99
+ "tags",
100
+ "trendingScore",
101
+ ],
102
+ "space": [
103
+ "author",
104
+ "createdAt",
105
+ "datasets",
106
+ "lastModified",
107
+ "likes",
108
+ "models",
109
+ "private",
110
+ "runtime",
111
+ "sdk",
112
+ "sha",
113
+ "subdomain",
114
+ "tags",
115
+ "trendingScore",
116
+ ],
117
+ }
118
+
119
+ # NOTE:
120
+ # The huggingface_hub client type literals currently advertise a few expand values
121
+ # that the live Hub API rejects (`childrenModelCount`, `usedStorage`) and omits a
122
+ # few that the API now accepts (`xetEnabled`, `gitalyUid`). Keep this allowlist in
123
+ # sync with the live API error contract rather than the client typing surface so we
124
+ # can sanitize generated requests before they hit the network.
125
+ REPO_SEARCH_ALLOWED_EXPAND: dict[str, list[str]] = {
126
+ "dataset": [
127
+ "author",
128
+ "cardData",
129
+ "citation",
130
+ "createdAt",
131
+ "description",
132
+ "disabled",
133
+ "downloads",
134
+ "downloadsAllTime",
135
+ "gated",
136
+ "lastModified",
137
+ "likes",
138
+ "paperswithcode_id",
139
+ "private",
140
+ "resourceGroup",
141
+ "sha",
142
+ "siblings",
143
+ "tags",
144
+ "trendingScore",
145
+ "xetEnabled",
146
+ "gitalyUid",
147
+ ],
148
+ "model": [
149
+ "author",
150
+ "baseModels",
151
+ "cardData",
152
+ "config",
153
+ "createdAt",
154
+ "disabled",
155
+ "downloads",
156
+ "downloadsAllTime",
157
+ "evalResults",
158
+ "gated",
159
+ "gguf",
160
+ "inference",
161
+ "inferenceProviderMapping",
162
+ "lastModified",
163
+ "library_name",
164
+ "likes",
165
+ "mask_token",
166
+ "model-index",
167
+ "pipeline_tag",
168
+ "private",
169
+ "resourceGroup",
170
+ "safetensors",
171
+ "sha",
172
+ "siblings",
173
+ "spaces",
174
+ "tags",
175
+ "transformersInfo",
176
+ "trendingScore",
177
+ "widgetData",
178
+ "xetEnabled",
179
+ "gitalyUid",
180
+ ],
181
+ "space": [
182
+ "author",
183
+ "cardData",
184
+ "createdAt",
185
+ "datasets",
186
+ "disabled",
187
+ "lastModified",
188
+ "likes",
189
+ "models",
190
+ "private",
191
+ "resourceGroup",
192
+ "runtime",
193
+ "sdk",
194
+ "sha",
195
+ "siblings",
196
+ "subdomain",
197
+ "tags",
198
+ "trendingScore",
199
+ "xetEnabled",
200
+ "gitalyUid",
201
+ ],
202
+ }
203
+
204
+ RUNTIME_CAPABILITY_FIELDS = [
205
+ "allowed_sections",
206
+ "overview",
207
+ "helpers",
208
+ "helper_contracts",
209
+ "helper_defaults",
210
+ "fields",
211
+ "limits",
212
+ "repo_search",
213
+ ]
214
+ REPO_SUMMARY_FIELDS = list(REPO_CANONICAL_FIELDS)
215
+ REPO_SUMMARY_OPTIONAL_FIELDS = [
216
+ field
217
+ for field in REPO_CANONICAL_FIELDS
218
+ if field not in {"repo_id", "repo_type", "author", "repo_url"}
219
+ ]
220
+ ACTOR_OPTIONAL_FIELDS = [
221
+ field for field in ACTOR_CANONICAL_FIELDS if field != "username"
222
+ ]
223
+ PROFILE_OPTIONAL_FIELDS = [
224
+ field
225
+ for field in PROFILE_CANONICAL_FIELDS
226
+ if field not in {"handle", "entity_type"}
227
+ ]
228
+ TRENDING_DEFAULT_FIELDS = [*REPO_SUMMARY_FIELDS, "trending_rank"]
229
+ TRENDING_OPTIONAL_FIELDS = [
230
+ field
231
+ for field in TRENDING_DEFAULT_FIELDS
232
+ if field not in {"repo_id", "repo_type", "author", "repo_url", "trending_rank"}
233
+ ]
234
+ DAILY_PAPER_DEFAULT_FIELDS = list(DAILY_PAPER_CANONICAL_FIELDS)
235
+ DAILY_PAPER_OPTIONAL_FIELDS = [
236
+ field
237
+ for field in DAILY_PAPER_CANONICAL_FIELDS
238
+ if field not in {"paper_id", "title", "published_at", "rank"}
239
+ ]
240
+ COLLECTION_DEFAULT_FIELDS = list(COLLECTION_CANONICAL_FIELDS)
241
+ COLLECTION_OPTIONAL_FIELDS = [
242
+ field
243
+ for field in COLLECTION_CANONICAL_FIELDS
244
+ if field not in {"collection_id", "title", "owner"}
245
+ ]
246
+
247
+
248
+ def _metadata(
249
+ *,
250
+ default_fields: list[str],
251
+ guaranteed_fields: list[str],
252
+ notes: str,
253
+ optional_fields: list[str] | None = None,
254
+ default_upstream_calls: int = 1,
255
+ may_fan_out: bool = False,
256
+ default_limit: int | None = None,
257
+ max_limit: int | None = None,
258
+ ) -> dict[str, Any]:
259
+ metadata: dict[str, Any] = {
260
+ "default_fields": list(default_fields),
261
+ "guaranteed_fields": list(guaranteed_fields),
262
+ "optional_fields": list(
263
+ optional_fields
264
+ if optional_fields is not None
265
+ else [
266
+ field for field in default_fields if field not in set(guaranteed_fields)
267
+ ]
268
+ ),
269
+ "default_upstream_calls": default_upstream_calls,
270
+ "may_fan_out": may_fan_out,
271
+ "notes": notes,
272
+ }
273
+ if default_limit is not None:
274
+ metadata["default_limit"] = default_limit
275
+ if max_limit is not None:
276
+ metadata["max_limit"] = max_limit
277
+ return metadata
278
+
279
+
280
+ def _config(
281
+ name: str,
282
+ *,
283
+ endpoint_patterns: tuple[str, ...] = (),
284
+ default_metadata: Mapping[str, Any],
285
+ pagination: Mapping[str, Any] | None = None,
286
+ ) -> HelperConfig:
287
+ return HelperConfig(
288
+ name=name,
289
+ endpoint_patterns=endpoint_patterns,
290
+ default_metadata=dict(default_metadata),
291
+ pagination=dict(pagination or {}),
292
+ )
293
+
294
+
295
+ HELPER_CONFIGS: dict[str, HelperConfig] = {
296
+ "hf_runtime_capabilities": _config(
297
+ "hf_runtime_capabilities",
298
+ default_metadata=_metadata(
299
+ default_fields=RUNTIME_CAPABILITY_FIELDS,
300
+ guaranteed_fields=RUNTIME_CAPABILITY_FIELDS,
301
+ optional_fields=[],
302
+ default_upstream_calls=0,
303
+ notes="Introspection helper. Use section=... to narrow the response.",
304
+ ),
305
+ ),
306
+ "hf_whoami": _config(
307
+ "hf_whoami",
308
+ endpoint_patterns=(r"^/api/whoami-v2$",),
309
+ default_metadata=_metadata(
310
+ default_fields=["username", "fullname", "is_pro"],
311
+ guaranteed_fields=["username"],
312
+ notes="Returns the current authenticated user when a request token is available.",
313
+ ),
314
+ ),
315
+ "hf_profile_summary": _config(
316
+ "hf_profile_summary",
317
+ endpoint_patterns=(
318
+ r"^/api/users/[^/]+/overview$",
319
+ r"^/api/organizations/[^/]+/overview$",
320
+ ),
321
+ default_metadata=_metadata(
322
+ default_fields=list(PROFILE_CANONICAL_FIELDS),
323
+ guaranteed_fields=["handle", "entity_type"],
324
+ optional_fields=PROFILE_OPTIONAL_FIELDS,
325
+ may_fan_out=True,
326
+ notes=(
327
+ "Profile summary helper. Aggregate counts like followers_count/following_count "
328
+ "are in the base item. include=['likes', 'activity'] adds composed samples and "
329
+ "extra upstream work; no other include values are supported. Overview-owned "
330
+ "repo counts may differ slightly from visible public search/list results."
331
+ ),
332
+ ),
333
+ ),
334
+ "hf_org_members": _config(
335
+ "hf_org_members",
336
+ endpoint_patterns=(r"^/api/organizations/[^/]+/members$",),
337
+ default_metadata=_metadata(
338
+ default_fields=list(ACTOR_CANONICAL_FIELDS),
339
+ guaranteed_fields=["username"],
340
+ optional_fields=ACTOR_OPTIONAL_FIELDS,
341
+ default_limit=1_000,
342
+ max_limit=GRAPH_SCAN_LIMIT_CAP,
343
+ notes="Returns organization member summary rows.",
344
+ ),
345
+ pagination={"default_limit": 1_000, "scan_max": GRAPH_SCAN_LIMIT_CAP},
346
+ ),
347
+ "hf_models_search": _config(
348
+ "hf_models_search",
349
+ endpoint_patterns=(r"^/api/models$",),
350
+ default_metadata=_metadata(
351
+ default_fields=REPO_SUMMARY_FIELDS,
352
+ guaranteed_fields=["repo_id", "repo_type", "author", "repo_url"],
353
+ optional_fields=REPO_SUMMARY_OPTIONAL_FIELDS,
354
+ default_limit=100,
355
+ max_limit=5_000,
356
+ notes=(
357
+ "Thin model-search wrapper around the Hub list_models path. Prefer this "
358
+ "over hf_repo_search for model-only queries. This is a one-shot selective "
359
+ "search; if meta.limit_boundary_hit is true, more rows may exist and counts "
360
+ "are not exact."
361
+ ),
362
+ ),
363
+ pagination={"default_limit": 100, "max_limit": 5_000},
364
+ ),
365
+ "hf_datasets_search": _config(
366
+ "hf_datasets_search",
367
+ endpoint_patterns=(r"^/api/datasets$",),
368
+ default_metadata=_metadata(
369
+ default_fields=REPO_SUMMARY_FIELDS,
370
+ guaranteed_fields=["repo_id", "repo_type", "author", "repo_url"],
371
+ optional_fields=REPO_SUMMARY_OPTIONAL_FIELDS,
372
+ default_limit=100,
373
+ max_limit=5_000,
374
+ notes=(
375
+ "Thin dataset-search wrapper around the Hub list_datasets path. Prefer "
376
+ "this over hf_repo_search for dataset-only queries. This is a one-shot "
377
+ "selective search; if meta.limit_boundary_hit is true, more rows may exist "
378
+ "and counts are not exact."
379
+ ),
380
+ ),
381
+ pagination={"default_limit": 100, "max_limit": 5_000},
382
+ ),
383
+ "hf_spaces_search": _config(
384
+ "hf_spaces_search",
385
+ endpoint_patterns=(r"^/api/spaces$",),
386
+ default_metadata=_metadata(
387
+ default_fields=REPO_SUMMARY_FIELDS,
388
+ guaranteed_fields=["repo_id", "repo_type", "author", "repo_url"],
389
+ optional_fields=REPO_SUMMARY_OPTIONAL_FIELDS,
390
+ default_limit=100,
391
+ max_limit=5_000,
392
+ notes=(
393
+ "Thin space-search wrapper around the Hub list_spaces path. Prefer this "
394
+ "over hf_repo_search for space-only queries. This is a one-shot selective "
395
+ "search; if meta.limit_boundary_hit is true, more rows may exist and counts "
396
+ "are not exact."
397
+ ),
398
+ ),
399
+ pagination={"default_limit": 100, "max_limit": 5_000},
400
+ ),
401
+ "hf_repo_search": _config(
402
+ "hf_repo_search",
403
+ endpoint_patterns=(r"^/api/models$", r"^/api/datasets$", r"^/api/spaces$"),
404
+ default_metadata=_metadata(
405
+ default_fields=REPO_SUMMARY_FIELDS,
406
+ guaranteed_fields=["repo_id", "repo_type", "author", "repo_url"],
407
+ optional_fields=REPO_SUMMARY_OPTIONAL_FIELDS,
408
+ default_limit=100,
409
+ max_limit=5_000,
410
+ notes=(
411
+ "Small generic repo-search helper. Prefer hf_models_search, "
412
+ "hf_datasets_search, or hf_spaces_search for single-type queries; use "
413
+ "hf_repo_search for intentionally cross-type search. This is a one-shot "
414
+ "selective search; if meta.limit_boundary_hit is true, more rows may exist "
415
+ "and counts are not exact."
416
+ ),
417
+ ),
418
+ pagination={"default_limit": 100, "max_limit": 5_000},
419
+ ),
420
+ "hf_user_graph": _config(
421
+ "hf_user_graph",
422
+ endpoint_patterns=(
423
+ r"^/api/users/[^/]+/(followers|following)$",
424
+ r"^/api/organizations/[^/]+/followers$",
425
+ ),
426
+ default_metadata=_metadata(
427
+ default_fields=list(ACTOR_CANONICAL_FIELDS),
428
+ guaranteed_fields=["username"],
429
+ optional_fields=ACTOR_OPTIONAL_FIELDS,
430
+ default_limit=1_000,
431
+ max_limit=GRAPH_SCAN_LIMIT_CAP,
432
+ notes="Returns followers/following summary rows.",
433
+ ),
434
+ pagination={
435
+ "default_limit": 1_000,
436
+ "max_limit": GRAPH_SCAN_LIMIT_CAP,
437
+ "scan_max": GRAPH_SCAN_LIMIT_CAP,
438
+ },
439
+ ),
440
+ "hf_repo_likers": _config(
441
+ "hf_repo_likers",
442
+ endpoint_patterns=(
443
+ r"^/api/(models|datasets|spaces)/(?:[^/]+|[^/]+/[^/]+)/likers$",
444
+ ),
445
+ default_metadata=_metadata(
446
+ default_fields=list(ACTOR_CANONICAL_FIELDS),
447
+ guaranteed_fields=["username"],
448
+ optional_fields=ACTOR_OPTIONAL_FIELDS,
449
+ default_limit=1_000,
450
+ notes="Returns users who liked a repo.",
451
+ ),
452
+ pagination={"default_limit": 1_000},
453
+ ),
454
+ "hf_user_likes": _config(
455
+ "hf_user_likes",
456
+ endpoint_patterns=(r"^/api/users/[^/]+/likes$",),
457
+ default_metadata=_metadata(
458
+ default_fields=list(USER_LIKES_CANONICAL_FIELDS),
459
+ guaranteed_fields=["liked_at", "repo_id", "repo_type"],
460
+ optional_fields=["repo_author", "repo_likes", "repo_downloads", "repo_url"],
461
+ default_limit=100,
462
+ max_limit=2_000,
463
+ may_fan_out=True,
464
+ notes=(
465
+ "Default recency mode is cheap. Popularity-ranked sorts use canonical keys "
466
+ "liked_at/repo_likes/repo_downloads and rerank only a bounded recent "
467
+ "shortlist. Check meta.ranking_complete / meta.ranking_window when ranking "
468
+ "by popularity; helper-owned coverage matters here."
469
+ ),
470
+ ),
471
+ pagination={
472
+ "default_limit": 100,
473
+ "enrich_max": LIKES_ENRICHMENT_MAX_REPOS,
474
+ "ranking_default": LIKES_RANKING_WINDOW_DEFAULT,
475
+ "scan_max": LIKES_SCAN_LIMIT_CAP,
476
+ },
477
+ ),
478
+ "hf_recent_activity": _config(
479
+ "hf_recent_activity",
480
+ endpoint_patterns=(r"^/api/recent-activity$",),
481
+ default_metadata=_metadata(
482
+ default_fields=list(ACTIVITY_CANONICAL_FIELDS),
483
+ guaranteed_fields=["event_type", "timestamp"],
484
+ optional_fields=["repo_id", "repo_type"],
485
+ default_limit=100,
486
+ max_limit=2_000,
487
+ may_fan_out=True,
488
+ notes=(
489
+ "Activity helper may fetch multiple pages when requested coverage exceeds "
490
+ "one page. count_only may still be a lower bound unless the feed exhausts "
491
+ "before max_pages."
492
+ ),
493
+ ),
494
+ pagination={
495
+ "default_limit": 100,
496
+ "max_pages": RECENT_ACTIVITY_SCAN_MAX_PAGES,
497
+ "page_limit": RECENT_ACTIVITY_PAGE_SIZE,
498
+ },
499
+ ),
500
+ "hf_repo_discussions": _config(
501
+ "hf_repo_discussions",
502
+ endpoint_patterns=(r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions$",),
503
+ default_metadata=_metadata(
504
+ default_fields=list(DISCUSSION_CANONICAL_FIELDS),
505
+ guaranteed_fields=["num", "title", "author", "status"],
506
+ optional_fields=["repo_id", "repo_type", "created_at", "url"],
507
+ default_limit=20,
508
+ max_limit=200,
509
+ notes="Discussion summary helper.",
510
+ ),
511
+ ),
512
+ "hf_repo_discussion_details": _config(
513
+ "hf_repo_discussion_details",
514
+ endpoint_patterns=(
515
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions/\d+$",
516
+ ),
517
+ default_metadata=_metadata(
518
+ default_fields=list(DISCUSSION_DETAIL_CANONICAL_FIELDS),
519
+ guaranteed_fields=["repo_id", "repo_type", "title", "author", "status"],
520
+ optional_fields=[
521
+ "num",
522
+ "created_at",
523
+ "url",
524
+ "comment_count",
525
+ "latest_comment_author",
526
+ "latest_comment_created_at",
527
+ "latest_comment_text",
528
+ "latest_comment_html",
529
+ ],
530
+ notes="Exact discussion detail helper.",
531
+ ),
532
+ ),
533
+ "hf_repo_details": _config(
534
+ "hf_repo_details",
535
+ endpoint_patterns=(r"^/api/(models|datasets|spaces)/[^/]+/[^/]+$",),
536
+ default_metadata=_metadata(
537
+ default_fields=REPO_SUMMARY_FIELDS,
538
+ guaranteed_fields=["repo_id", "repo_type", "author", "repo_url"],
539
+ optional_fields=REPO_SUMMARY_OPTIONAL_FIELDS,
540
+ may_fan_out=True,
541
+ notes="Exact repo metadata path. Multiple repo_ids may trigger one detail call per requested repo.",
542
+ ),
543
+ ),
544
+ "hf_trending": _config(
545
+ "hf_trending",
546
+ endpoint_patterns=(r"^/api/trending$",),
547
+ default_metadata=_metadata(
548
+ default_fields=TRENDING_DEFAULT_FIELDS,
549
+ guaranteed_fields=[
550
+ "repo_id",
551
+ "repo_type",
552
+ "author",
553
+ "repo_url",
554
+ "trending_rank",
555
+ ],
556
+ optional_fields=TRENDING_OPTIONAL_FIELDS,
557
+ default_limit=20,
558
+ max_limit=TRENDING_ENDPOINT_MAX_LIMIT,
559
+ notes="Returns ordered trending summary rows only. Use hf_repo_details for exact repo metadata.",
560
+ ),
561
+ pagination={"default_limit": 20, "max_limit": TRENDING_ENDPOINT_MAX_LIMIT},
562
+ ),
563
+ "hf_daily_papers": _config(
564
+ "hf_daily_papers",
565
+ endpoint_patterns=(r"^/api/daily_papers$",),
566
+ default_metadata=_metadata(
567
+ default_fields=DAILY_PAPER_DEFAULT_FIELDS,
568
+ guaranteed_fields=["paper_id", "title", "published_at", "rank"],
569
+ optional_fields=DAILY_PAPER_OPTIONAL_FIELDS,
570
+ default_limit=20,
571
+ max_limit=OUTPUT_ITEMS_TRUNCATION_LIMIT,
572
+ notes="Returns daily paper summary rows. repo_id is omitted unless the upstream payload provides it.",
573
+ ),
574
+ pagination={"default_limit": 20, "max_limit": OUTPUT_ITEMS_TRUNCATION_LIMIT},
575
+ ),
576
+ "hf_collections_search": _config(
577
+ "hf_collections_search",
578
+ endpoint_patterns=(r"^/api/collections$",),
579
+ default_metadata=_metadata(
580
+ default_fields=COLLECTION_DEFAULT_FIELDS,
581
+ guaranteed_fields=["collection_id", "title", "owner"],
582
+ optional_fields=COLLECTION_OPTIONAL_FIELDS,
583
+ default_limit=20,
584
+ max_limit=OUTPUT_ITEMS_TRUNCATION_LIMIT,
585
+ notes="Collection summary helper.",
586
+ ),
587
+ pagination={"default_limit": 20, "max_limit": OUTPUT_ITEMS_TRUNCATION_LIMIT},
588
+ ),
589
+ "hf_collection_items": _config(
590
+ "hf_collection_items",
591
+ endpoint_patterns=(
592
+ r"^/api/collections/[^/]+$",
593
+ r"^/api/collections/[^/]+/[^/]+$",
594
+ ),
595
+ default_metadata=_metadata(
596
+ default_fields=REPO_SUMMARY_FIELDS,
597
+ guaranteed_fields=["repo_id", "repo_type", "repo_url"],
598
+ optional_fields=[
599
+ field
600
+ for field in REPO_CANONICAL_FIELDS
601
+ if field not in {"repo_id", "repo_type", "repo_url"}
602
+ ],
603
+ default_limit=100,
604
+ max_limit=OUTPUT_ITEMS_TRUNCATION_LIMIT,
605
+ notes="Returns repos inside one collection as summary rows.",
606
+ ),
607
+ pagination={"default_limit": 100, "max_limit": OUTPUT_ITEMS_TRUNCATION_LIMIT},
608
+ ),
609
+ }
610
+
611
+ HELPER_EXTERNALS = tuple(HELPER_CONFIGS)
612
+
613
+ HELPER_DEFAULT_METADATA: dict[str, dict[str, Any]] = {
614
+ name: dict(config.default_metadata) for name, config in HELPER_CONFIGS.items()
615
+ }
616
+
617
+ PAGINATION_POLICY: dict[str, dict[str, Any]] = {
618
+ name: dict(config.pagination)
619
+ for name, config in HELPER_CONFIGS.items()
620
+ if config.pagination
621
+ }
622
+
623
+ HELPER_COVERED_ENDPOINT_PATTERNS: list[tuple[str, str]] = [
624
+ (pattern, config.name)
625
+ for config in HELPER_CONFIGS.values()
626
+ for pattern in config.endpoint_patterns
627
+ ]
628
+
629
+ ALLOWLIST_PATTERNS = [
630
+ r"^/api/whoami-v2$",
631
+ r"^/api/trending$",
632
+ r"^/api/daily_papers$",
633
+ r"^/api/models$",
634
+ r"^/api/datasets$",
635
+ r"^/api/spaces$",
636
+ r"^/api/models-tags-by-type$",
637
+ r"^/api/datasets-tags-by-type$",
638
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+$",
639
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions$",
640
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions/\d+$",
641
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions/\d+/status$",
642
+ r"^/api/users/[^/]+/overview$",
643
+ r"^/api/users/[^/]+/socials$",
644
+ r"^/api/users/[^/]+/followers$",
645
+ r"^/api/users/[^/]+/following$",
646
+ r"^/api/users/[^/]+/likes$",
647
+ r"^/api/(models|datasets|spaces)/(?:[^/]+|[^/]+/[^/]+)/likers$",
648
+ r"^/api/organizations/[^/]+/overview$",
649
+ r"^/api/organizations/[^/]+/members$",
650
+ r"^/api/organizations/[^/]+/followers$",
651
+ r"^/api/collections$",
652
+ r"^/api/collections/[^/]+$",
653
+ r"^/api/collections/[^/]+/[^/]+$",
654
+ r"^/api/recent-activity$",
655
+ ]
656
+
657
+ STRICT_ALLOWLIST_PATTERNS = [
658
+ r"^/api/users/[^/]+/overview$",
659
+ r"^/api/users/[^/]+/socials$",
660
+ r"^/api/whoami-v2$",
661
+ r"^/api/trending$",
662
+ r"^/api/daily_papers$",
663
+ r"^/api/(models|datasets|spaces)/(?:[^/]+|[^/]+/[^/]+)/likers$",
664
+ r"^/api/collections$",
665
+ r"^/api/collections/[^/]+$",
666
+ r"^/api/collections/[^/]+/[^/]+$",
667
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions$",
668
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions/\d+$",
669
+ r"^/api/(models|datasets|spaces)/[^/]+/[^/]+/discussions/\d+/status$",
670
+ ]
671
+
672
+ REPO_API_ADAPTERS: dict[str, RepoApiAdapter] = {
673
+ "model": RepoApiAdapter(
674
+ list_method_name="list_models", detail_method_name="model_info"
675
+ ),
676
+ "dataset": RepoApiAdapter(
677
+ list_method_name="list_datasets", detail_method_name="dataset_info"
678
+ ),
679
+ "space": RepoApiAdapter(
680
+ list_method_name="list_spaces", detail_method_name="space_info"
681
+ ),
682
+ }
monty_api/runtime_context.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any, Callable, NamedTuple, cast
8
+
9
+ import httpx
10
+
11
+ from .constants import MAX_CALLS_LIMIT
12
+ from .helpers.activity import register_activity_helpers
13
+ from .helpers.collections import register_collection_helpers
14
+ from .helpers.introspection import register_introspection_helpers
15
+ from .helpers.profiles import register_profile_helpers
16
+ from .helpers.repos import register_repo_helpers
17
+ from .http_runtime import (
18
+ _as_int,
19
+ _author_from_any,
20
+ _canonical_repo_type,
21
+ _clamp_int,
22
+ _coerce_str_list,
23
+ _dt_to_str,
24
+ _extract_author_names,
25
+ _extract_num_params,
26
+ _extract_profile_name,
27
+ _load_token,
28
+ _normalize_collection_repo_item,
29
+ _normalize_daily_paper_row,
30
+ _normalize_repo_detail_row,
31
+ _normalize_repo_search_row,
32
+ _normalize_repo_sort_key,
33
+ _normalize_trending_row,
34
+ _optional_str_list,
35
+ _repo_detail_call,
36
+ _repo_list_call,
37
+ _repo_web_url,
38
+ _sort_repo_rows,
39
+ call_api_host,
40
+ )
41
+ from .registry import PAGINATION_POLICY
42
+ from .runtime_envelopes import (
43
+ _build_exhaustive_meta,
44
+ _build_exhaustive_result_meta,
45
+ _derive_can_request_more,
46
+ _derive_limit_metadata,
47
+ _derive_more_available,
48
+ _derive_next_request_hint,
49
+ _derive_truncated_by,
50
+ _helper_error,
51
+ _helper_meta,
52
+ _helper_success,
53
+ _overview_count_only_success,
54
+ _resolve_exhaustive_limits,
55
+ )
56
+ from .runtime_filtering import (
57
+ _apply_where,
58
+ _helper_item,
59
+ _item_matches_where,
60
+ _normalize_where,
61
+ _overview_count,
62
+ _project_activity_items,
63
+ _project_actor_items,
64
+ _project_collection_items,
65
+ _project_discussion_detail_items,
66
+ _project_discussion_items,
67
+ _project_daily_paper_items,
68
+ _project_items,
69
+ _project_repo_items,
70
+ _project_user_items,
71
+ _project_user_like_items,
72
+ )
73
+ from .validation import _resolve_helper_functions
74
+
75
+ if TYPE_CHECKING:
76
+ from huggingface_hub import HfApi
77
+
78
+
79
+ class RuntimeHelperEnvironment(NamedTuple):
80
+ context: "RuntimeContext"
81
+ call_count: dict[str, int]
82
+ trace: list[dict[str, Any]]
83
+ limit_summaries: list[dict[str, Any]]
84
+ latest_helper_error_box: dict[str, dict[str, Any] | None]
85
+ internal_helper_used: dict[str, bool]
86
+ helper_functions: dict[str, Callable[..., Any]]
87
+
88
+
89
+ def _execution_debug_enabled() -> bool:
90
+ for key in ("MONTY_DEBUG_EXECUTION", "MONTY_DEBUG_QUERY"):
91
+ value = os.environ.get(key, "")
92
+ if value.strip().lower() in {"1", "true", "yes", "on"}:
93
+ return True
94
+ return False
95
+
96
+
97
+ def _debug_log(*parts: Any) -> None:
98
+ if not _execution_debug_enabled():
99
+ return
100
+ print("[monty-debug]", *parts, file=sys.stderr)
101
+ sys.stderr.flush()
102
+
103
+
104
+ def _hf_call_timeout_default() -> int:
105
+ raw = os.environ.get("MONTY_HF_CALL_TIMEOUT_SEC", "20").strip()
106
+ try:
107
+ return max(1, int(raw))
108
+ except Exception:
109
+ return 20
110
+
111
+
112
+ _HF_CLIENT_TIMEOUT_SEC: int | None = None
113
+
114
+
115
+ def _configure_hf_client_factory(timeout_sec: int) -> None:
116
+ global _HF_CLIENT_TIMEOUT_SEC
117
+ if _HF_CLIENT_TIMEOUT_SEC == timeout_sec:
118
+ return
119
+ from huggingface_hub.utils._http import hf_request_event_hook, set_client_factory
120
+
121
+ set_client_factory(
122
+ lambda: httpx.Client(
123
+ event_hooks={"request": [hf_request_event_hook]},
124
+ follow_redirects=True,
125
+ timeout=float(timeout_sec),
126
+ )
127
+ )
128
+ _HF_CLIENT_TIMEOUT_SEC = timeout_sec
129
+
130
+
131
+ @dataclass(slots=True)
132
+ class RuntimeContext:
133
+ max_calls: int
134
+ strict_mode: bool
135
+ timeout_sec: int
136
+ call_count: dict[str, int] = field(default_factory=lambda: {"n": 0})
137
+ trace: list[dict[str, Any]] = field(default_factory=list)
138
+ limit_summaries: list[dict[str, Any]] = field(default_factory=list)
139
+ latest_helper_error_box: dict[str, dict[str, Any] | None] = field(
140
+ default_factory=lambda: {"value": None}
141
+ )
142
+ internal_helper_used: dict[str, bool] = field(
143
+ default_factory=lambda: {"used": False}
144
+ )
145
+ helper_registry: dict[str, Callable[..., Any]] = field(default_factory=dict)
146
+ _hf_api_client: "HfApi | None" = field(default=None, init=False, repr=False)
147
+
148
+ def _budget_remaining(self) -> int:
149
+ return max(0, self.max_calls - self.call_count["n"])
150
+
151
+ def _policy_int(self, helper_name: str, key: str, default: int) -> int:
152
+ cfg = PAGINATION_POLICY.get(helper_name) or {}
153
+ try:
154
+ return int(cfg.get(key, default))
155
+ except Exception:
156
+ return int(default)
157
+
158
+ def _consume_call(self, endpoint: str, method: str = "GET") -> int:
159
+ if self.call_count["n"] >= self.max_calls:
160
+ raise RuntimeError(f"Max API calls exceeded ({self.max_calls})")
161
+ self.call_count["n"] += 1
162
+ return self.call_count["n"]
163
+
164
+ def _trace_ok(
165
+ self, idx: int, endpoint: str, method: str = "GET", status: int = 200
166
+ ) -> None:
167
+ self.trace.append(
168
+ {
169
+ "call_index": idx,
170
+ "depth": idx,
171
+ "method": method,
172
+ "endpoint": endpoint,
173
+ "ok": True,
174
+ "status": status,
175
+ }
176
+ )
177
+
178
+ def _trace_err(
179
+ self, idx: int, endpoint: str, err: Any, method: str = "GET", status: int = 0
180
+ ) -> None:
181
+ self.trace.append(
182
+ {
183
+ "call_index": idx,
184
+ "depth": idx,
185
+ "method": method,
186
+ "endpoint": endpoint,
187
+ "ok": False,
188
+ "status": status,
189
+ "error": str(err),
190
+ }
191
+ )
192
+
193
+ def _host_raw_call(
194
+ self,
195
+ endpoint: str,
196
+ *,
197
+ params: dict[str, Any] | None = None,
198
+ method: str = "GET",
199
+ json_body: dict[str, Any] | None = None,
200
+ ) -> dict[str, Any]:
201
+ idx = self._consume_call(endpoint, method)
202
+ started = time.perf_counter()
203
+ _debug_log("host_raw:start", f"call={idx}", method, endpoint)
204
+ try:
205
+ resp = call_api_host(
206
+ endpoint,
207
+ method=method,
208
+ params=params,
209
+ json_body=json_body,
210
+ timeout_sec=self.timeout_sec,
211
+ strict_mode=self.strict_mode,
212
+ )
213
+ if resp.get("ok"):
214
+ self._trace_ok(
215
+ idx, endpoint, method=method, status=int(resp.get("status") or 200)
216
+ )
217
+ else:
218
+ self._trace_err(
219
+ idx,
220
+ endpoint,
221
+ resp.get("error"),
222
+ method=method,
223
+ status=int(resp.get("status") or 0),
224
+ )
225
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
226
+ _debug_log(
227
+ "host_raw:end",
228
+ f"call={idx}",
229
+ method,
230
+ endpoint,
231
+ f"ok={bool(resp.get('ok'))}",
232
+ f"status={resp.get('status')}",
233
+ f"elapsed_ms={elapsed_ms}",
234
+ )
235
+ return resp
236
+ except Exception as exc:
237
+ self._trace_err(idx, endpoint, exc, method=method, status=0)
238
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
239
+ _debug_log(
240
+ "host_raw:error",
241
+ f"call={idx}",
242
+ method,
243
+ endpoint,
244
+ type(exc).__name__,
245
+ str(exc),
246
+ f"elapsed_ms={elapsed_ms}",
247
+ )
248
+ raise
249
+
250
+ def _get_hf_api_client(self) -> "HfApi":
251
+ if self._hf_api_client is None:
252
+ from huggingface_hub import HfApi
253
+
254
+ endpoint = os.getenv("HF_ENDPOINT", "https://huggingface.co").rstrip("/")
255
+ _configure_hf_client_factory(
256
+ max(1, min(self.timeout_sec, _hf_call_timeout_default()))
257
+ )
258
+ self._hf_api_client = HfApi(endpoint=endpoint, token=_load_token())
259
+ return self._hf_api_client
260
+
261
+ def _host_hf_call(self, endpoint: str, fn: Callable[[], Any]) -> Any:
262
+ idx = self._consume_call(endpoint, "GET")
263
+ started = time.perf_counter()
264
+ timeout_sec = max(1, min(self.timeout_sec, _hf_call_timeout_default()))
265
+ _debug_log(
266
+ "host_hf:start",
267
+ f"call={idx}",
268
+ endpoint,
269
+ f"timeout_sec={timeout_sec}",
270
+ )
271
+ try:
272
+ out = fn()
273
+ self._trace_ok(idx, endpoint, method="GET", status=200)
274
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
275
+ _debug_log("host_hf:end", f"call={idx}", endpoint, f"elapsed_ms={elapsed_ms}")
276
+ return out
277
+ except Exception as exc:
278
+ self._trace_err(idx, endpoint, exc, method="GET", status=0)
279
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
280
+ _debug_log(
281
+ "host_hf:error",
282
+ f"call={idx}",
283
+ endpoint,
284
+ type(exc).__name__,
285
+ str(exc),
286
+ f"elapsed_ms={elapsed_ms}",
287
+ )
288
+ raise
289
+
290
+ async def call_helper(self, helper_name: str, /, *args: Any, **kwargs: Any) -> Any:
291
+ fn = self.helper_registry.get(helper_name)
292
+ if not callable(fn):
293
+ raise RuntimeError(f"Helper '{helper_name}' is not registered")
294
+ started = time.perf_counter()
295
+ _debug_log(
296
+ "runtime_helper:start",
297
+ helper_name,
298
+ f"args={len(args)}",
299
+ f"kwargs={sorted(kwargs)}",
300
+ f"budget_remaining={self._budget_remaining()}",
301
+ )
302
+ try:
303
+ result = await cast(Callable[..., Any], fn)(*args, **kwargs)
304
+ except Exception as exc:
305
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
306
+ _debug_log(
307
+ "runtime_helper:error",
308
+ helper_name,
309
+ type(exc).__name__,
310
+ str(exc),
311
+ f"elapsed_ms={elapsed_ms}",
312
+ )
313
+ raise
314
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
315
+ ok = result.get("ok") if isinstance(result, dict) else None
316
+ _debug_log(
317
+ "runtime_helper:end",
318
+ helper_name,
319
+ f"ok={ok}",
320
+ f"elapsed_ms={elapsed_ms}",
321
+ f"budget_remaining={self._budget_remaining()}",
322
+ )
323
+ return result
324
+
325
+
326
+ for name, value in {
327
+ "_helper_meta": _helper_meta,
328
+ "_derive_limit_metadata": _derive_limit_metadata,
329
+ "_derive_more_available": _derive_more_available,
330
+ "_derive_truncated_by": _derive_truncated_by,
331
+ "_derive_can_request_more": _derive_can_request_more,
332
+ "_derive_next_request_hint": _derive_next_request_hint,
333
+ "_resolve_exhaustive_limits": _resolve_exhaustive_limits,
334
+ "_build_exhaustive_meta": _build_exhaustive_meta,
335
+ "_overview_count_only_success": _overview_count_only_success,
336
+ "_build_exhaustive_result_meta": _build_exhaustive_result_meta,
337
+ "_helper_success": _helper_success,
338
+ "_helper_error": _helper_error,
339
+ "_project_items": _project_items,
340
+ "_project_repo_items": _project_repo_items,
341
+ "_project_collection_items": _project_collection_items,
342
+ "_project_discussion_items": _project_discussion_items,
343
+ "_project_discussion_detail_items": _project_discussion_detail_items,
344
+ "_project_daily_paper_items": _project_daily_paper_items,
345
+ "_project_user_items": _project_user_items,
346
+ "_project_actor_items": _project_actor_items,
347
+ "_project_user_like_items": _project_user_like_items,
348
+ "_project_activity_items": _project_activity_items,
349
+ "_normalize_where": _normalize_where,
350
+ "_item_matches_where": _item_matches_where,
351
+ "_apply_where": _apply_where,
352
+ "_helper_item": _helper_item,
353
+ "_overview_count": _overview_count,
354
+ "_as_int": staticmethod(_as_int),
355
+ "_author_from_any": staticmethod(_author_from_any),
356
+ "_canonical_repo_type": staticmethod(_canonical_repo_type),
357
+ "_clamp_int": staticmethod(_clamp_int),
358
+ "_coerce_str_list": staticmethod(_coerce_str_list),
359
+ "_dt_to_str": staticmethod(_dt_to_str),
360
+ "_extract_author_names": staticmethod(_extract_author_names),
361
+ "_extract_num_params": staticmethod(_extract_num_params),
362
+ "_extract_profile_name": staticmethod(_extract_profile_name),
363
+ "_load_token": staticmethod(_load_token),
364
+ "_normalize_collection_repo_item": staticmethod(_normalize_collection_repo_item),
365
+ "_normalize_daily_paper_row": staticmethod(_normalize_daily_paper_row),
366
+ "_normalize_repo_detail_row": staticmethod(_normalize_repo_detail_row),
367
+ "_normalize_repo_search_row": staticmethod(_normalize_repo_search_row),
368
+ "_normalize_repo_sort_key": staticmethod(_normalize_repo_sort_key),
369
+ "_normalize_trending_row": staticmethod(_normalize_trending_row),
370
+ "_optional_str_list": staticmethod(_optional_str_list),
371
+ "_repo_detail_call": staticmethod(_repo_detail_call),
372
+ "_repo_list_call": staticmethod(_repo_list_call),
373
+ "_repo_web_url": staticmethod(_repo_web_url),
374
+ "_sort_repo_rows": staticmethod(_sort_repo_rows),
375
+ }.items():
376
+ setattr(RuntimeContext, name, value)
377
+
378
+
379
+ def build_runtime_helper_environment(
380
+ *,
381
+ max_calls: int,
382
+ strict_mode: bool,
383
+ timeout_sec: int,
384
+ ) -> RuntimeHelperEnvironment:
385
+ ctx = RuntimeContext(
386
+ max_calls=max(1, min(int(max_calls), MAX_CALLS_LIMIT)),
387
+ strict_mode=strict_mode,
388
+ timeout_sec=timeout_sec,
389
+ )
390
+
391
+ for registration in (
392
+ register_profile_helpers,
393
+ register_repo_helpers,
394
+ register_activity_helpers,
395
+ register_collection_helpers,
396
+ register_introspection_helpers,
397
+ ):
398
+ ctx.helper_registry.update(registration(ctx))
399
+
400
+ helper_functions = _resolve_helper_functions(ctx.helper_registry)
401
+ return RuntimeHelperEnvironment(
402
+ context=ctx,
403
+ call_count=ctx.call_count,
404
+ trace=ctx.trace,
405
+ limit_summaries=ctx.limit_summaries,
406
+ latest_helper_error_box=ctx.latest_helper_error_box,
407
+ internal_helper_used=ctx.internal_helper_used,
408
+ helper_functions=helper_functions,
409
+ )
monty_api/runtime_envelopes.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .http_runtime import _as_int, _clamp_int
6
+
7
+
8
+ def _helper_meta(
9
+ self: Any, start_calls: int, *, source: str, **extra: Any
10
+ ) -> dict[str, Any]:
11
+ out = {
12
+ "source": source,
13
+ "normalized": True,
14
+ "budget_used": max(0, self.call_count["n"] - start_calls),
15
+ "budget_remaining": self._budget_remaining(),
16
+ }
17
+ out.update(extra)
18
+ return out
19
+
20
+
21
+ def _derive_limit_metadata(
22
+ self: Any,
23
+ *,
24
+ requested_limit: int | None,
25
+ applied_limit: int,
26
+ default_limit_used: bool,
27
+ requested_scan_limit: int | None = None,
28
+ applied_scan_limit: int | None = None,
29
+ requested_max_pages: int | None = None,
30
+ applied_max_pages: int | None = None,
31
+ ) -> dict[str, Any]:
32
+ meta: dict[str, Any] = {
33
+ "requested_limit": requested_limit,
34
+ "applied_limit": applied_limit,
35
+ "default_limit_used": default_limit_used,
36
+ }
37
+ if requested_scan_limit is not None or applied_scan_limit is not None:
38
+ meta["requested_scan_limit"] = requested_scan_limit
39
+ meta["scan_limit"] = applied_scan_limit
40
+ meta["scan_limit_applied"] = requested_scan_limit != applied_scan_limit
41
+ if requested_max_pages is not None or applied_max_pages is not None:
42
+ meta["requested_max_pages"] = requested_max_pages
43
+ meta["applied_max_pages"] = applied_max_pages
44
+ meta["page_limit_applied"] = requested_max_pages != applied_max_pages
45
+ if requested_limit is not None:
46
+ meta["hard_cap_applied"] = applied_limit < requested_limit
47
+ return meta
48
+
49
+
50
+ def _derive_more_available(
51
+ self: Any,
52
+ *,
53
+ sample_complete: bool,
54
+ exact_count: bool,
55
+ returned: int,
56
+ total: int | None,
57
+ ) -> bool | str:
58
+ if sample_complete:
59
+ return False
60
+ if exact_count and total is not None and returned < total:
61
+ return True
62
+ return "unknown"
63
+
64
+
65
+ def _derive_truncated_by(
66
+ self: Any,
67
+ *,
68
+ hard_cap: bool = False,
69
+ scan_limit_hit: bool = False,
70
+ page_limit_hit: bool = False,
71
+ limit_hit: bool = False,
72
+ ) -> str:
73
+ causes = [hard_cap, scan_limit_hit, page_limit_hit, limit_hit]
74
+ if sum(1 for cause in causes if cause) > 1:
75
+ return "multiple"
76
+ if hard_cap:
77
+ return "hard_cap"
78
+ if scan_limit_hit:
79
+ return "scan_limit"
80
+ if page_limit_hit:
81
+ return "page_limit"
82
+ if limit_hit:
83
+ return "limit"
84
+ return "none"
85
+
86
+
87
+ def _derive_can_request_more(
88
+ self: Any, *, sample_complete: bool, truncated_by: str
89
+ ) -> bool:
90
+ if sample_complete:
91
+ return False
92
+ return truncated_by in {"limit", "scan_limit", "page_limit", "multiple"}
93
+
94
+
95
+ def _derive_next_request_hint(
96
+ self: Any,
97
+ *,
98
+ truncated_by: str,
99
+ more_available: bool | str,
100
+ applied_limit: int,
101
+ applied_scan_limit: int | None = None,
102
+ applied_max_pages: int | None = None,
103
+ ) -> str:
104
+ if truncated_by == "limit":
105
+ return f"Ask for limit>{applied_limit} to see more rows"
106
+ if truncated_by == "scan_limit" and applied_scan_limit is not None:
107
+ return f"Increase scan_limit above {applied_scan_limit} for broader coverage"
108
+ if truncated_by == "page_limit" and applied_max_pages is not None:
109
+ return f"Increase max_pages above {applied_max_pages} to continue paging"
110
+ if truncated_by == "hard_cap":
111
+ return "No more rows can be returned in a single call because a hard cap was applied"
112
+ if truncated_by == "multiple":
113
+ return "Increase the relevant return/page/scan bounds to improve coverage"
114
+ if more_available is False:
115
+ return "No more results available"
116
+ if more_available == "unknown":
117
+ return "More results may exist; narrow filters or raise scan/page bounds for better coverage"
118
+ return "Ask for a larger limit to see more rows"
119
+
120
+
121
+ def _resolve_exhaustive_limits(
122
+ self: Any,
123
+ *,
124
+ limit: int | None,
125
+ count_only: bool,
126
+ default_limit: int,
127
+ max_limit: int,
128
+ scan_limit: int | None = None,
129
+ scan_cap: int | None = None,
130
+ ) -> dict[str, Any]:
131
+ requested_limit = None if count_only else limit
132
+ effective_requested_limit = 0 if count_only else requested_limit
133
+ out: dict[str, Any] = {
134
+ "requested_limit": requested_limit,
135
+ "applied_limit": _clamp_int(
136
+ effective_requested_limit,
137
+ default=default_limit,
138
+ minimum=0,
139
+ maximum=max_limit,
140
+ ),
141
+ "default_limit_used": requested_limit is None and not count_only,
142
+ }
143
+ out["hard_cap_applied"] = (
144
+ requested_limit is not None and out["applied_limit"] < requested_limit
145
+ )
146
+ if scan_cap is not None:
147
+ out["requested_scan_limit"] = scan_limit
148
+ out["applied_scan_limit"] = _clamp_int(
149
+ scan_limit,
150
+ default=scan_cap,
151
+ minimum=1,
152
+ maximum=scan_cap,
153
+ )
154
+ return out
155
+
156
+
157
+ def _build_exhaustive_meta(
158
+ self: Any,
159
+ *,
160
+ base_meta: dict[str, Any],
161
+ limit_plan: dict[str, Any],
162
+ sample_complete: bool,
163
+ exact_count: bool,
164
+ truncated_by: str,
165
+ more_available: bool | str,
166
+ requested_max_pages: int | None = None,
167
+ applied_max_pages: int | None = None,
168
+ ) -> dict[str, Any]:
169
+ meta = dict(base_meta)
170
+ applied_limit = int(limit_plan["applied_limit"])
171
+ applied_scan_limit = limit_plan.get("applied_scan_limit")
172
+ meta.update(
173
+ {
174
+ "complete": sample_complete,
175
+ "exact_count": exact_count,
176
+ "sample_complete": sample_complete,
177
+ "more_available": more_available,
178
+ "can_request_more": _derive_can_request_more(
179
+ self,
180
+ sample_complete=sample_complete,
181
+ truncated_by=truncated_by,
182
+ ),
183
+ "truncated_by": truncated_by,
184
+ "next_request_hint": _derive_next_request_hint(
185
+ self,
186
+ truncated_by=truncated_by,
187
+ more_available=more_available,
188
+ applied_limit=applied_limit,
189
+ applied_scan_limit=applied_scan_limit
190
+ if isinstance(applied_scan_limit, int)
191
+ else None,
192
+ applied_max_pages=applied_max_pages,
193
+ ),
194
+ }
195
+ )
196
+ meta.update(
197
+ _derive_limit_metadata(
198
+ self,
199
+ requested_limit=limit_plan["requested_limit"],
200
+ applied_limit=applied_limit,
201
+ default_limit_used=bool(limit_plan["default_limit_used"]),
202
+ requested_scan_limit=limit_plan.get("requested_scan_limit"),
203
+ applied_scan_limit=applied_scan_limit
204
+ if isinstance(applied_scan_limit, int)
205
+ else None,
206
+ requested_max_pages=requested_max_pages,
207
+ applied_max_pages=applied_max_pages,
208
+ )
209
+ )
210
+ return meta
211
+
212
+
213
+ def _overview_count_only_success(
214
+ self: Any,
215
+ *,
216
+ start_calls: int,
217
+ source: str,
218
+ total: int,
219
+ limit_plan: dict[str, Any],
220
+ base_meta: dict[str, Any],
221
+ ) -> dict[str, Any]:
222
+ meta = _build_exhaustive_meta(
223
+ self,
224
+ base_meta={
225
+ **base_meta,
226
+ "matched": total,
227
+ "returned": 0,
228
+ "total": total,
229
+ "total_available": total,
230
+ "total_matched": total,
231
+ "truncated": False,
232
+ },
233
+ limit_plan=limit_plan,
234
+ sample_complete=True,
235
+ exact_count=True,
236
+ truncated_by="none",
237
+ more_available=False,
238
+ )
239
+ return _helper_success(
240
+ self,
241
+ start_calls=start_calls,
242
+ source=source,
243
+ items=[],
244
+ meta=meta,
245
+ )
246
+
247
+
248
+ def _build_exhaustive_result_meta(
249
+ self: Any,
250
+ *,
251
+ base_meta: dict[str, Any],
252
+ limit_plan: dict[str, Any],
253
+ matched_count: int,
254
+ returned_count: int,
255
+ exact_count: bool,
256
+ count_only: bool = False,
257
+ sample_complete: bool | None = None,
258
+ more_available: bool | str | None = None,
259
+ scan_limit_hit: bool = False,
260
+ page_limit_hit: bool = False,
261
+ truncated_extra: bool = False,
262
+ requested_max_pages: int | None = None,
263
+ applied_max_pages: int | None = None,
264
+ ) -> dict[str, Any]:
265
+ applied_limit = int(limit_plan["applied_limit"])
266
+ if count_only:
267
+ effective_sample_complete = exact_count
268
+ else:
269
+ effective_sample_complete = (
270
+ sample_complete
271
+ if isinstance(sample_complete, bool)
272
+ else exact_count and matched_count <= applied_limit
273
+ )
274
+ limit_hit = (
275
+ False
276
+ if count_only
277
+ else (applied_limit > 0 and matched_count > applied_limit)
278
+ )
279
+ truncated_by = _derive_truncated_by(
280
+ self,
281
+ hard_cap=bool(limit_plan.get("hard_cap_applied")),
282
+ scan_limit_hit=scan_limit_hit,
283
+ page_limit_hit=page_limit_hit,
284
+ limit_hit=limit_hit,
285
+ )
286
+ truncated = truncated_by != "none" or truncated_extra
287
+ total_value = _as_int(base_meta.get("total"))
288
+ effective_more_available = more_available
289
+ if count_only and exact_count:
290
+ effective_more_available = False
291
+ if effective_more_available is None:
292
+ effective_more_available = _derive_more_available(
293
+ self,
294
+ sample_complete=effective_sample_complete,
295
+ exact_count=exact_count,
296
+ returned=returned_count,
297
+ total=total_value,
298
+ )
299
+
300
+ return _build_exhaustive_meta(
301
+ self,
302
+ base_meta={
303
+ **base_meta,
304
+ "matched": matched_count,
305
+ "returned": returned_count,
306
+ "truncated": truncated,
307
+ },
308
+ limit_plan=limit_plan,
309
+ sample_complete=effective_sample_complete,
310
+ exact_count=exact_count,
311
+ truncated_by=truncated_by,
312
+ more_available=effective_more_available,
313
+ requested_max_pages=requested_max_pages,
314
+ applied_max_pages=applied_max_pages,
315
+ )
316
+
317
+
318
+ def _helper_success(
319
+ self: Any,
320
+ *,
321
+ start_calls: int,
322
+ source: str,
323
+ items: list[dict[str, Any]],
324
+ cursor: str | None = None,
325
+ meta: dict[str, Any] | None = None,
326
+ **extra_meta: Any,
327
+ ) -> dict[str, Any]:
328
+ merged_meta = dict(meta or {})
329
+ merged_meta.update(extra_meta)
330
+ if cursor is not None:
331
+ merged_meta["cursor"] = cursor
332
+ return {
333
+ "ok": True,
334
+ "item": items[0] if len(items) == 1 else None,
335
+ "items": items,
336
+ "meta": _helper_meta(self, start_calls, source=source, **merged_meta),
337
+ "error": None,
338
+ }
339
+
340
+
341
+ def _helper_error(
342
+ self: Any,
343
+ *,
344
+ start_calls: int,
345
+ source: str,
346
+ error: Any,
347
+ **meta: Any,
348
+ ) -> dict[str, Any]:
349
+ envelope = {
350
+ "ok": False,
351
+ "item": None,
352
+ "items": [],
353
+ "meta": _helper_meta(self, start_calls, source=source, **meta),
354
+ "error": str(error),
355
+ }
356
+ self.latest_helper_error_box["value"] = envelope
357
+ return envelope
monty_api/runtime_filtering.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from .constants import (
7
+ ACTIVITY_CANONICAL_FIELDS,
8
+ ACTOR_CANONICAL_FIELDS,
9
+ COLLECTION_CANONICAL_FIELDS,
10
+ DAILY_PAPER_CANONICAL_FIELDS,
11
+ DISCUSSION_CANONICAL_FIELDS,
12
+ DISCUSSION_DETAIL_CANONICAL_FIELDS,
13
+ REPO_CANONICAL_FIELDS,
14
+ USER_CANONICAL_FIELDS,
15
+ USER_LIKES_CANONICAL_FIELDS,
16
+ )
17
+ from .http_runtime import _as_int
18
+
19
+
20
+ def _as_datetime(value: Any) -> datetime | None:
21
+ if not isinstance(value, str):
22
+ return None
23
+ text = value.strip()
24
+ if not text:
25
+ return None
26
+ if text.endswith("Z"):
27
+ text = f"{text[:-1]}+00:00"
28
+ try:
29
+ parsed = datetime.fromisoformat(text)
30
+ except Exception:
31
+ return None
32
+ if parsed.tzinfo is None:
33
+ return parsed.replace(tzinfo=UTC)
34
+ return parsed
35
+
36
+
37
+ def _allowed_field_set(allowed_fields: tuple[str, ...] | list[str] | set[str]) -> set[str]:
38
+ return {str(field).strip() for field in allowed_fields if str(field).strip()}
39
+
40
+
41
+ def _project_items(
42
+ self: Any,
43
+ items: list[dict[str, Any]],
44
+ fields: list[str] | None,
45
+ *,
46
+ allowed_fields: tuple[str, ...] | list[str] | set[str] | None = None,
47
+ ) -> list[dict[str, Any]]:
48
+ if not isinstance(fields, list) or not fields:
49
+ return items
50
+ wanted = [str(field).strip() for field in fields if str(field).strip()]
51
+ if not wanted:
52
+ return items
53
+ if allowed_fields is not None:
54
+ allowed = _allowed_field_set(allowed_fields)
55
+ invalid = sorted(field for field in wanted if field not in allowed)
56
+ if invalid:
57
+ raise ValueError(
58
+ f"Unsupported fields {invalid}. Allowed fields: {sorted(allowed)}"
59
+ )
60
+ projected: list[dict[str, Any]] = []
61
+ for row in items:
62
+ out: dict[str, Any] = {}
63
+ for key in wanted:
64
+ value = row.get(key)
65
+ if value is None:
66
+ continue
67
+ out[key] = value
68
+ projected.append(out)
69
+ return projected
70
+
71
+
72
+ def _project_repo_items(
73
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
74
+ ) -> list[dict[str, Any]]:
75
+ return _project_items(self, items, fields, allowed_fields=REPO_CANONICAL_FIELDS)
76
+
77
+
78
+ def _project_collection_items(
79
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
80
+ ) -> list[dict[str, Any]]:
81
+ return _project_items(
82
+ self, items, fields, allowed_fields=COLLECTION_CANONICAL_FIELDS
83
+ )
84
+
85
+
86
+ def _project_daily_paper_items(
87
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
88
+ ) -> list[dict[str, Any]]:
89
+ return _project_items(
90
+ self, items, fields, allowed_fields=DAILY_PAPER_CANONICAL_FIELDS
91
+ )
92
+
93
+
94
+ def _project_user_items(
95
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
96
+ ) -> list[dict[str, Any]]:
97
+ return _project_items(self, items, fields, allowed_fields=USER_CANONICAL_FIELDS)
98
+
99
+
100
+ def _project_actor_items(
101
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
102
+ ) -> list[dict[str, Any]]:
103
+ return _project_items(self, items, fields, allowed_fields=ACTOR_CANONICAL_FIELDS)
104
+
105
+
106
+ def _project_user_like_items(
107
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
108
+ ) -> list[dict[str, Any]]:
109
+ return _project_items(
110
+ self, items, fields, allowed_fields=USER_LIKES_CANONICAL_FIELDS
111
+ )
112
+
113
+
114
+ def _project_activity_items(
115
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
116
+ ) -> list[dict[str, Any]]:
117
+ return _project_items(
118
+ self, items, fields, allowed_fields=ACTIVITY_CANONICAL_FIELDS
119
+ )
120
+
121
+
122
+ def _project_discussion_items(
123
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
124
+ ) -> list[dict[str, Any]]:
125
+ return _project_items(
126
+ self, items, fields, allowed_fields=DISCUSSION_CANONICAL_FIELDS
127
+ )
128
+
129
+
130
+ def _project_discussion_detail_items(
131
+ self: Any, items: list[dict[str, Any]], fields: list[str] | None
132
+ ) -> list[dict[str, Any]]:
133
+ return _project_items(
134
+ self, items, fields, allowed_fields=DISCUSSION_DETAIL_CANONICAL_FIELDS
135
+ )
136
+
137
+
138
+ def _normalize_where(
139
+ self: Any,
140
+ where: dict[str, Any] | None,
141
+ *,
142
+ allowed_fields: tuple[str, ...] | list[str] | set[str] | None = None,
143
+ ) -> dict[str, Any] | None:
144
+ if not isinstance(where, dict) or not where:
145
+ return where
146
+ allowed = _allowed_field_set(allowed_fields) if allowed_fields is not None else None
147
+ normalized: dict[str, Any] = {}
148
+ for key, value in where.items():
149
+ raw_key = str(key).strip()
150
+ if not raw_key:
151
+ continue
152
+ if allowed is not None and raw_key not in allowed:
153
+ raise ValueError(
154
+ f"Unsupported filter fields {[raw_key]}. Allowed fields: {sorted(allowed)}"
155
+ )
156
+ normalized[raw_key] = value
157
+ return normalized
158
+
159
+
160
+ def _item_matches_where(
161
+ self: Any, item: dict[str, Any], where: dict[str, Any] | None
162
+ ) -> bool:
163
+ if not isinstance(where, dict) or not where:
164
+ return True
165
+ for key, cond in where.items():
166
+ value = item.get(str(key))
167
+ if isinstance(cond, dict):
168
+ if "eq" in cond and value != cond.get("eq"):
169
+ return False
170
+ if "in" in cond:
171
+ allowed = cond.get("in")
172
+ if isinstance(allowed, (list, tuple, set)) and value not in allowed:
173
+ return False
174
+ if "contains" in cond:
175
+ needle = cond.get("contains")
176
+ if (
177
+ not isinstance(value, str)
178
+ or not isinstance(needle, str)
179
+ or needle not in value
180
+ ):
181
+ return False
182
+ if "icontains" in cond:
183
+ needle = cond.get("icontains")
184
+ if (
185
+ not isinstance(value, str)
186
+ or not isinstance(needle, str)
187
+ or needle.lower() not in value.lower()
188
+ ):
189
+ return False
190
+ if "gte" in cond:
191
+ left = _as_int(value)
192
+ right = _as_int(cond.get("gte"))
193
+ if left is not None and right is not None:
194
+ if left < right:
195
+ return False
196
+ else:
197
+ left_dt = _as_datetime(value)
198
+ right_dt = _as_datetime(cond.get("gte"))
199
+ if left_dt is None or right_dt is None or left_dt < right_dt:
200
+ return False
201
+ if "lte" in cond:
202
+ left = _as_int(value)
203
+ right = _as_int(cond.get("lte"))
204
+ if left is not None and right is not None:
205
+ if left > right:
206
+ return False
207
+ else:
208
+ left_dt = _as_datetime(value)
209
+ right_dt = _as_datetime(cond.get("lte"))
210
+ if left_dt is None or right_dt is None or left_dt > right_dt:
211
+ return False
212
+ continue
213
+ if isinstance(cond, (list, tuple, set)):
214
+ if value not in cond:
215
+ return False
216
+ continue
217
+ if value != cond:
218
+ return False
219
+ return True
220
+
221
+
222
+ def _apply_where(
223
+ self: Any,
224
+ items: list[dict[str, Any]],
225
+ where: dict[str, Any] | None,
226
+ *,
227
+ allowed_fields: tuple[str, ...] | list[str] | set[str] | None = None,
228
+ ) -> list[dict[str, Any]]:
229
+ normalized_where = _normalize_where(self, where, allowed_fields=allowed_fields)
230
+ if not isinstance(normalized_where, dict) or not normalized_where:
231
+ return items
232
+ return [row for row in items if _item_matches_where(self, row, normalized_where)]
233
+
234
+
235
+ def _helper_item(self: Any, resp: dict[str, Any]) -> dict[str, Any] | None:
236
+ item = resp.get("item")
237
+ if isinstance(item, dict):
238
+ return item
239
+ items = resp.get("items")
240
+ if isinstance(items, list) and items and isinstance(items[0], dict):
241
+ return items[0]
242
+ return None
243
+
244
+
245
+ def _overview_count(self: Any, item: dict[str, Any] | None, key: str) -> int | None:
246
+ if not isinstance(item, dict):
247
+ return None
248
+ return _as_int(item.get(key))
monty_api/tool_entrypoints.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """File-based function tool entrypoints for the production Monty runtime."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _PACKAGE_DIR = Path(__file__).resolve().parent
11
+ _ROOT_DIR = _PACKAGE_DIR.parent
12
+ for candidate in (_ROOT_DIR, _PACKAGE_DIR):
13
+ candidate_str = str(candidate)
14
+ if candidate_str not in sys.path:
15
+ sys.path.insert(0, candidate_str)
16
+
17
+ from monty_api import ( # noqa: E402
18
+ HELPER_EXTERNALS,
19
+ hf_hub_query as _hf_hub_query,
20
+ hf_hub_query_raw as _hf_hub_query_raw,
21
+ main,
22
+ )
23
+
24
+
25
+ async def hf_hub_query(
26
+ code: str,
27
+ query: str | None = None,
28
+ max_calls: int | None = None,
29
+ timeout_sec: int | None = None,
30
+ ) -> dict[str, Any]:
31
+ return await _hf_hub_query(
32
+ code=code,
33
+ query=query,
34
+ max_calls=max_calls,
35
+ timeout_sec=timeout_sec,
36
+ )
37
+
38
+
39
+ async def hf_hub_query_raw(
40
+ code: str,
41
+ query: str | None = None,
42
+ max_calls: int | None = None,
43
+ timeout_sec: int | None = None,
44
+ ) -> Any:
45
+ return await _hf_hub_query_raw(
46
+ code=code,
47
+ query=query,
48
+ max_calls=max_calls,
49
+ timeout_sec=timeout_sec,
50
+ )
51
+
52
+ __all__ = [
53
+ "HELPER_EXTERNALS",
54
+ "hf_hub_query",
55
+ "hf_hub_query_raw",
56
+ "main",
57
+ ]
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
monty_api/validation.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import os
5
+ import re
6
+ import tokenize
7
+ from io import StringIO
8
+ from typing import Any, Callable, cast
9
+
10
+ from .constants import (
11
+ GRAPH_SCAN_LIMIT_CAP,
12
+ LIKES_SCAN_LIMIT_CAP,
13
+ OUTPUT_ITEMS_TRUNCATION_LIMIT,
14
+ SELECTIVE_ENDPOINT_RETURN_HARD_CAP,
15
+ TRENDING_ENDPOINT_MAX_LIMIT,
16
+ )
17
+ from .registry import (
18
+ ALLOWLIST_PATTERNS,
19
+ HELPER_EXTERNALS,
20
+ STRICT_ALLOWLIST_PATTERNS,
21
+ )
22
+
23
+
24
+ def _resolve_helper_functions(
25
+ namespace: dict[str, Any],
26
+ ) -> dict[str, Callable[..., Any]]:
27
+ resolved: dict[str, Callable[..., Any]] = {}
28
+ for helper_name in HELPER_EXTERNALS:
29
+ candidate = namespace.get(helper_name)
30
+ if not callable(candidate):
31
+ raise RuntimeError(f"Helper '{helper_name}' is not defined or not callable")
32
+ resolved[helper_name] = cast(Callable[..., Any], candidate)
33
+ return resolved
34
+
35
+
36
+ def _normalize_endpoint(endpoint: str) -> str:
37
+ ep = (endpoint or "").strip()
38
+ if not ep:
39
+ raise ValueError("endpoint is required")
40
+ if "?" in ep:
41
+ raise ValueError("endpoint must not include query string; use params")
42
+ if ep.startswith("http://") or ep.startswith("https://"):
43
+ raise ValueError("endpoint must be path-only")
44
+ if not ep.startswith("/"):
45
+ ep = "/" + ep
46
+ if not ep.startswith("/api/"):
47
+ ep = "/api" + ep
48
+ if ep in {"/api/collections/search", "/api/collections/search/"}:
49
+ ep = "/api/collections"
50
+ if ".." in ep:
51
+ raise ValueError("path traversal not allowed")
52
+ return ep
53
+
54
+
55
+ def _endpoint_allowed(endpoint: str, strict_mode: bool) -> bool:
56
+ path = endpoint.split("?", 1)[0]
57
+ patterns = STRICT_ALLOWLIST_PATTERNS if strict_mode else ALLOWLIST_PATTERNS
58
+ return any(re.match(p, path) for p in patterns)
59
+
60
+
61
+ def _sanitize_params(endpoint: str, params: dict[str, Any] | None) -> dict[str, Any]:
62
+ clean = dict(params or {})
63
+ path = endpoint.split("?", 1)[0]
64
+
65
+ if path == "/api/collections":
66
+ if "q" not in clean and "search" in clean:
67
+ clean["q"] = clean.get("search")
68
+ clean.pop("search", None)
69
+
70
+ if path == "/api/trending":
71
+ t = str(clean.get("type") or "").strip().lower()
72
+ aliases = {"models": "model", "datasets": "dataset", "spaces": "space"}
73
+ if t in aliases:
74
+ clean["type"] = aliases[t]
75
+ lim = clean.get("limit")
76
+ if lim is not None:
77
+ try:
78
+ n = int(lim)
79
+ except Exception:
80
+ n = TRENDING_ENDPOINT_MAX_LIMIT
81
+ clean["limit"] = max(1, min(n, TRENDING_ENDPOINT_MAX_LIMIT))
82
+ return clean
83
+
84
+ lim = clean.get("limit")
85
+ if lim is None:
86
+ return clean
87
+ try:
88
+ n = int(lim)
89
+ except Exception:
90
+ return clean
91
+
92
+ endpoint_limit_max = SELECTIVE_ENDPOINT_RETURN_HARD_CAP
93
+ if re.match(r"^/api/users/[^/]+/(followers|following)$", path):
94
+ endpoint_limit_max = GRAPH_SCAN_LIMIT_CAP
95
+ elif re.match(r"^/api/users/[^/]+/likes$", path):
96
+ endpoint_limit_max = LIKES_SCAN_LIMIT_CAP
97
+
98
+ clean["limit"] = max(1, min(n, endpoint_limit_max))
99
+ return clean
100
+
101
+
102
+ def _truncate_result_payload(output: Any) -> Any:
103
+ if not isinstance(output, dict):
104
+ return output
105
+
106
+ items = output.get("items")
107
+ if not isinstance(items, list) or len(items) <= OUTPUT_ITEMS_TRUNCATION_LIMIT:
108
+ return output
109
+
110
+ trimmed = dict(output)
111
+ trimmed_items = items[:OUTPUT_ITEMS_TRUNCATION_LIMIT]
112
+ trimmed["items"] = trimmed_items
113
+ trimmed["item"] = trimmed_items[0] if len(trimmed_items) == 1 else None
114
+ note = f"truncated items to first {OUTPUT_ITEMS_TRUNCATION_LIMIT} rows for token efficiency"
115
+ steps = trimmed.get("steps")
116
+ if isinstance(steps, list):
117
+ trimmed["steps"] = [*steps, note]
118
+ else:
119
+ trimmed["steps"] = [note]
120
+ return trimmed
121
+
122
+
123
+ def _verbose_result_meta_enabled() -> bool:
124
+ value = os.environ.get("MONTY_VERBOSE_RESULT_META", "")
125
+ return value.strip().lower() in {"1", "true", "yes", "on"}
126
+
127
+
128
+ def _is_helper_meta_dict(value: Any) -> bool:
129
+ return (
130
+ isinstance(value, dict)
131
+ and isinstance(value.get("source"), str)
132
+ and (
133
+ value.get("normalized") is True
134
+ or "budget_used" in value
135
+ or "budget_remaining" in value
136
+ )
137
+ )
138
+
139
+
140
+ def _helper_meta_is_partial(value: dict[str, Any]) -> bool:
141
+ return any(
142
+ [
143
+ value.get("truncated") is True,
144
+ value.get("more_available") not in {False, None},
145
+ value.get("limit_boundary_hit") is True,
146
+ value.get("sample_complete") is False,
147
+ value.get("exact_count") is False,
148
+ value.get("ranking_complete") is False,
149
+ value.get("ranking_window_hit") is True,
150
+ value.get("hard_cap_applied") is True,
151
+ ]
152
+ )
153
+
154
+
155
+ def _compact_helper_meta(value: dict[str, Any]) -> dict[str, Any]:
156
+ partial = _helper_meta_is_partial(value)
157
+ compact: dict[str, Any] = {
158
+ "partial": partial,
159
+ }
160
+ for key in (
161
+ "source",
162
+ "returned",
163
+ "total",
164
+ "matched",
165
+ "more_available",
166
+ "truncated",
167
+ "truncated_by",
168
+ "exact_count",
169
+ "sample_complete",
170
+ "hard_cap_applied",
171
+ "limit_boundary_hit",
172
+ "can_request_more",
173
+ "next_request_hint",
174
+ "ranking_window",
175
+ "ranking_window_hit",
176
+ "ranking_complete",
177
+ "ranking_next_request_hint",
178
+ "relation",
179
+ "username",
180
+ "organization",
181
+ "entity",
182
+ "entity_type",
183
+ "handle",
184
+ ):
185
+ if value.get(key) is not None:
186
+ compact[key] = value.get(key)
187
+ if compact.get("total") is None and value.get("total_available") is not None:
188
+ compact["total"] = value.get("total_available")
189
+ return compact
190
+
191
+
192
+ def _compact_result_metadata(value: Any) -> Any:
193
+ if _verbose_result_meta_enabled():
194
+ return value
195
+ if _is_helper_meta_dict(value):
196
+ return _compact_helper_meta(value)
197
+ if isinstance(value, dict):
198
+ return {key: _compact_result_metadata(item) for key, item in value.items()}
199
+ if isinstance(value, list):
200
+ return [_compact_result_metadata(item) for item in value]
201
+ return value
202
+
203
+
204
+ def _is_helper_envelope(output: Any) -> bool:
205
+ return (
206
+ isinstance(output, dict)
207
+ and isinstance(output.get("ok"), bool)
208
+ and "items" in output
209
+ and "meta" in output
210
+ and "error" in output
211
+ )
212
+
213
+
214
+ def _summarize_limit_hit(helper_name: str, result: Any) -> dict[str, Any] | None:
215
+ if not _is_helper_envelope(result):
216
+ return None
217
+ meta = result.get("meta") if isinstance(result.get("meta"), dict) else {}
218
+ if not isinstance(meta, dict):
219
+ return None
220
+
221
+ truncated_by = str(meta.get("truncated_by") or "")
222
+ limit_hit = any(
223
+ [
224
+ _helper_meta_is_partial(meta),
225
+ truncated_by in {"scan_limit", "page_limit", "multiple"},
226
+ ]
227
+ )
228
+ if not limit_hit:
229
+ return None
230
+
231
+ summary: dict[str, Any] = {
232
+ "helper": helper_name,
233
+ "source": meta.get("source"),
234
+ "returned": meta.get("returned"),
235
+ "total": meta.get("total"),
236
+ "truncated": meta.get("truncated"),
237
+ "truncated_by": meta.get("truncated_by"),
238
+ "more_available": meta.get("more_available"),
239
+ "requested_limit": meta.get("requested_limit"),
240
+ "applied_limit": meta.get("applied_limit"),
241
+ "next_request_hint": meta.get("next_request_hint"),
242
+ "limit_boundary_hit": meta.get("limit_boundary_hit"),
243
+ }
244
+ if meta.get("scan_limit") is not None:
245
+ summary["scan_limit"] = meta.get("scan_limit")
246
+ if meta.get("applied_max_pages") is not None:
247
+ summary["applied_max_pages"] = meta.get("applied_max_pages")
248
+ for key in (
249
+ "ranking_window",
250
+ "requested_ranking_window",
251
+ "ranking_window_applied",
252
+ "ranking_window_hit",
253
+ "ranking_complete",
254
+ "ranking_next_request_hint",
255
+ ):
256
+ if meta.get(key) is not None:
257
+ summary[key] = meta.get(key)
258
+ return summary
259
+
260
+
261
+ def _wrap_raw_result(
262
+ result: Any,
263
+ *,
264
+ ok: bool,
265
+ api_calls: int,
266
+ elapsed_ms: int,
267
+ limit_summaries: list[dict[str, Any]] | None = None,
268
+ error: str | None = None,
269
+ ) -> dict[str, Any]:
270
+ hits = [dict(summary) for summary in (limit_summaries or [])[:10]]
271
+ meta: dict[str, Any] = {
272
+ "ok": ok,
273
+ "api_calls": api_calls,
274
+ "elapsed_ms": elapsed_ms,
275
+ "limits_reached": bool(hits),
276
+ "limit_summary": hits,
277
+ }
278
+ if error is not None:
279
+ meta["error"] = error
280
+ return {
281
+ "result": result,
282
+ "meta": meta,
283
+ }
284
+
285
+
286
+ def _validate_generated_code(code: str) -> None:
287
+ if not code.strip():
288
+ raise ValueError("Generated code is empty")
289
+
290
+ blocked_patterns: list[tuple[str, str]] = [
291
+ (r"(?m)^\s*import\s+\S", "import statement"),
292
+ (r"(?m)^\s*from\s+\S+\s+import\s+\S", "from-import statement"),
293
+ (r"\bexec\s*\(", "exec("),
294
+ (r"\beval\s*\(", "eval("),
295
+ (r"\bopen\s*\(", "open("),
296
+ (r"\b__import__\b", "__import__"),
297
+ (r"(?i)\bwhile\s+true\b", "while true"),
298
+ ]
299
+ for pattern, label in blocked_patterns:
300
+ if re.search(pattern, code):
301
+ raise ValueError(f"Generated code contains blocked pattern: {label}")
302
+
303
+ try:
304
+ parsed = compile( # noqa: S102 - compile is used for AST validation only.
305
+ code,
306
+ "<generated-monty-code>",
307
+ "exec",
308
+ flags=ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT,
309
+ dont_inherit=True,
310
+ )
311
+ except SyntaxError as e:
312
+ message = e.msg or "invalid syntax"
313
+ raise ValueError(f"Generated code is not valid Python: {message}") from e
314
+
315
+ if not isinstance(parsed, ast.Module):
316
+ raise ValueError("Generated code must be a Python module")
317
+ if not parsed.body:
318
+ raise ValueError("Generated code is empty")
319
+
320
+ final_stmt = parsed.body[-1]
321
+ final_is_result = (
322
+ isinstance(final_stmt, ast.Expr)
323
+ and isinstance(final_stmt.value, ast.Name)
324
+ and final_stmt.value.id == "result"
325
+ )
326
+ if not final_is_result:
327
+ raise ValueError(
328
+ "Generated code must assign the final output to `result` and end with a final line containing only `result` (do not stop after `result = ...`)."
329
+ )
330
+
331
+ has_result_assignment = any(
332
+ isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id == "result"
333
+ for node in ast.walk(parsed)
334
+ )
335
+ if not has_result_assignment:
336
+ raise ValueError(
337
+ "Generated code must assign the final output to `result` before the final `result` line."
338
+ )
339
+
340
+ for node in ast.walk(parsed):
341
+ if not isinstance(node, ast.Call):
342
+ continue
343
+ if isinstance(node.func, ast.Name) and node.func.id == "call_api":
344
+ raise ValueError(
345
+ "Generated code must use documented hf_* helpers only; raw `call_api(...)` is not part of the prompt contract."
346
+ )
347
+
348
+ helper_name_set = set(HELPER_EXTERNALS)
349
+ has_external_call = any(
350
+ isinstance(node, ast.Call)
351
+ and isinstance(node.func, ast.Name)
352
+ and node.func.id in helper_name_set
353
+ for node in ast.walk(parsed)
354
+ )
355
+ if not has_external_call:
356
+ raise ValueError(
357
+ "Generated code must call at least one documented hf_* helper."
358
+ )
359
+
360
+
361
+ def _coerce_jsonish_python_literals(code: str) -> str:
362
+ """Normalize common JSON literals into valid Python names in generated code."""
363
+ replacements = {
364
+ "true": "True",
365
+ "false": "False",
366
+ "null": "None",
367
+ }
368
+
369
+ out_tokens: list[tuple[int, str]] = []
370
+ for tok in tokenize.generate_tokens(StringIO(code).readline):
371
+ tok_type = tok.type
372
+ tok_str = tok.string
373
+ if tok_type == tokenize.NAME and tok_str in replacements:
374
+ tok_str = replacements[tok_str]
375
+ out_tokens.append((tok_type, tok_str))
376
+ return tokenize.untokenize(out_tokens)
tool_entrypoints.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """File-based function tool entrypoints for the production Monty runtime."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _PACKAGE_DIR = Path(__file__).resolve().parent
11
+ _ROOT_DIR = _PACKAGE_DIR.parent
12
+ for candidate in (_ROOT_DIR, _PACKAGE_DIR):
13
+ candidate_str = str(candidate)
14
+ if candidate_str not in sys.path:
15
+ sys.path.insert(0, candidate_str)
16
+
17
+ from monty_api import ( # noqa: E402
18
+ HELPER_EXTERNALS,
19
+ hf_hub_query as _hf_hub_query,
20
+ hf_hub_query_raw as _hf_hub_query_raw,
21
+ main,
22
+ )
23
+
24
+
25
+ async def hf_hub_query(
26
+ code: str,
27
+ query: str | None = None,
28
+ max_calls: int | None = None,
29
+ timeout_sec: int | None = None,
30
+ ) -> dict[str, Any]:
31
+ return await _hf_hub_query(
32
+ code=code,
33
+ query=query,
34
+ max_calls=max_calls,
35
+ timeout_sec=timeout_sec,
36
+ )
37
+
38
+
39
+ async def hf_hub_query_raw(
40
+ code: str,
41
+ query: str | None = None,
42
+ max_calls: int | None = None,
43
+ timeout_sec: int | None = None,
44
+ ) -> Any:
45
+ return await _hf_hub_query_raw(
46
+ code=code,
47
+ query=query,
48
+ max_calls=max_calls,
49
+ timeout_sec=timeout_sec,
50
+ )
51
+
52
+ __all__ = [
53
+ "HELPER_EXTERNALS",
54
+ "hf_hub_query",
55
+ "hf_hub_query_raw",
56
+ "main",
57
+ ]
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
wheels/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
wheels/fast_agent_mcp-0.7.18-py3-none-any.whl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5eea5680f3cfcaf9ead6638044570ae5bbe40a60d36f18614902453d7f65b4f5
3
+ size 1901319