imho 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imho/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Python client for the imho.run agent API (Steam game recommendations).
2
+
3
+ >>> from imho import ImhoClient
4
+ >>> with ImhoClient() as imho:
5
+ ... for pick in imho.games_like("Hollow Knight", n=3)["results"]:
6
+ ... print(pick["rank"], pick["name"], "-", pick["why"])
7
+ """
8
+
9
+ from ._version import __version__
10
+ from .client import DEFAULT_BASE_URL, AsyncImhoClient, ImhoClient
11
+ from .errors import (
12
+ BadRequestError,
13
+ DisabledError,
14
+ ImhoError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ ToolError,
18
+ )
19
+
20
+ __all__ = [
21
+ "DEFAULT_BASE_URL",
22
+ "AsyncImhoClient",
23
+ "BadRequestError",
24
+ "DisabledError",
25
+ "ImhoClient",
26
+ "ImhoError",
27
+ "NotFoundError",
28
+ "RateLimitError",
29
+ "ToolError",
30
+ "__version__",
31
+ ]
imho/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
imho/client.py ADDED
@@ -0,0 +1,597 @@
1
+ """Sync and async clients for the imho.run agent API.
2
+
3
+ Most methods use the REST endpoints under ``/api/agent/``.
4
+ ``find_game_by_description`` exists only as an MCP tool, so it (and
5
+ :meth:`call_tool` for any other tool) goes through the MCP endpoint with a
6
+ single stateless JSON-RPC request.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import json
13
+ import re
14
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union, cast
15
+
16
+ import httpx
17
+
18
+ from ._version import __version__
19
+ from .errors import (
20
+ BadRequestError,
21
+ DisabledError,
22
+ ImhoError,
23
+ NotFoundError,
24
+ RateLimitError,
25
+ ToolError,
26
+ )
27
+ from .types import (
28
+ CoopMode,
29
+ Exclude,
30
+ FindGameResult,
31
+ GameFactsResult,
32
+ GamesLikeResult,
33
+ Lang,
34
+ NewReleasesResult,
35
+ Perspective,
36
+ Platform,
37
+ RecommendResult,
38
+ SearchResult,
39
+ SteamDeck,
40
+ TrendingKind,
41
+ TrendingResult,
42
+ )
43
+
44
+ DEFAULT_BASE_URL = "https://imho.run"
45
+ DEFAULT_TIMEOUT = 30.0
46
+ # find_game_by_description runs a language model; give it more room.
47
+ FIND_TIMEOUT = 90.0
48
+ MCP_PROTOCOL_VERSION = "2025-06-18"
49
+ USER_AGENT = f"imho-python/{__version__} (+https://github.com/0x216/imho-mcp)"
50
+
51
+ _RATE_TEXT = re.compile(r"Too many requests\. Retry after (\d+(?:\.\d+)?)s", re.I)
52
+ _ids = itertools.count(1)
53
+
54
+ Coop = Union[bool, CoopMode]
55
+ Params = List[Tuple[str, str]]
56
+ Request = Tuple[str, Params]
57
+
58
+
59
+ # ── request building (shared by both clients) ──────────────────────────────
60
+
61
+
62
+ def _check_n(n: int, maximum: int) -> str:
63
+ if not 1 <= n <= maximum:
64
+ raise ValueError(f"n must be between 1 and {maximum}")
65
+ return str(n)
66
+
67
+
68
+ def _flag(value: bool) -> str:
69
+ return "true" if value else "false"
70
+
71
+
72
+ def _coop_value(coop: Coop) -> Optional[str]:
73
+ if coop is False:
74
+ return None
75
+ if coop is True:
76
+ return "true"
77
+ if coop in ("online", "local"):
78
+ return cast(str, coop)
79
+ raise ValueError("coop must be True, False, 'online' or 'local'")
80
+
81
+
82
+ def _common_filters(
83
+ params: Params, free: bool, coop: Coop, steam_deck: Optional[SteamDeck]
84
+ ) -> None:
85
+ if free:
86
+ params.append(("free", "true"))
87
+ coop_value = _coop_value(coop)
88
+ if coop_value is not None:
89
+ params.append(("coop", coop_value))
90
+ if steam_deck is not None:
91
+ params.append(("deck", steam_deck))
92
+
93
+
94
+ def _games_like_request(
95
+ game: str, n: int, lang: Lang, free: bool, coop: Coop, steam_deck: Optional[SteamDeck]
96
+ ) -> Request:
97
+ params: Params = [("q", game), ("n", _check_n(n, 20)), ("lang", lang)]
98
+ _common_filters(params, free, coop, steam_deck)
99
+ return "/api/agent/games-like", params
100
+
101
+
102
+ def _game_facts_request(game: str, lang: Lang) -> Request:
103
+ return "/api/agent/game-facts", [("q", game), ("lang", lang)]
104
+
105
+
106
+ def _recommend_request(
107
+ seeds: Sequence[str],
108
+ *,
109
+ n: int,
110
+ lang: Lang,
111
+ liked: Sequence[str],
112
+ disliked: Sequence[str],
113
+ preferences: Optional[str],
114
+ free: bool,
115
+ coop: Coop,
116
+ steam_deck: Optional[SteamDeck],
117
+ exclude: Sequence[Exclude],
118
+ exclude_tags: Sequence[str],
119
+ year_min: Optional[int],
120
+ year_max: Optional[int],
121
+ upcoming: bool,
122
+ popularity_bias: float,
123
+ ) -> Request:
124
+ if isinstance(seeds, str):
125
+ seeds = [seeds]
126
+ if not seeds and not liked:
127
+ raise ValueError("give at least one seed game (or liked games)")
128
+ if len(seeds) > 3:
129
+ raise ValueError("at most 3 seeds; pass more games as liked=")
130
+ if not -1.0 <= popularity_bias <= 1.0:
131
+ raise ValueError("popularity_bias must be between -1 and 1")
132
+ params: Params = [("seed", s) for s in seeds]
133
+ params += [("n", _check_n(n, 24)), ("lang", lang)]
134
+ _common_filters(params, free, coop, steam_deck)
135
+ params += [("exclude", e) for e in exclude]
136
+ params += [("exclude_tags", t) for t in exclude_tags]
137
+ params += [("liked", g) for g in liked]
138
+ params += [("disliked", g) for g in disliked]
139
+ if preferences:
140
+ params.append(("preferences", preferences))
141
+ if year_min is not None:
142
+ params.append(("year_min", str(year_min)))
143
+ if year_max is not None:
144
+ params.append(("year_max", str(year_max)))
145
+ if upcoming:
146
+ params.append(("upcoming", "true"))
147
+ if popularity_bias:
148
+ params.append(("popularity_bias", str(popularity_bias)))
149
+ return "/api/agent/recommend", params
150
+
151
+
152
+ def _trending_request(kind: TrendingKind, n: int, lang: Lang) -> Request:
153
+ if kind not in ("rising", "breakouts"):
154
+ raise ValueError("kind must be 'rising' or 'breakouts'")
155
+ return "/api/agent/trending", [("kind", kind), ("n", _check_n(n, 20)), ("lang", lang)]
156
+
157
+
158
+ def _new_releases_request(upcoming: bool, coop: bool, n: int, lang: Lang) -> Request:
159
+ params: Params = [("n", _check_n(n, 20)), ("lang", lang)]
160
+ if upcoming:
161
+ params.append(("upcoming", _flag(upcoming)))
162
+ if coop:
163
+ params.append(("coop", _flag(coop)))
164
+ return "/api/agent/new-releases", params
165
+
166
+
167
+ def _search_request(query: str, n: int) -> Request:
168
+ if len(query.strip()) < 2:
169
+ raise ValueError("query must be at least 2 characters")
170
+ return "/api/agent/search", [("q", query), ("n", _check_n(n, 10))]
171
+
172
+
173
+ def _find_args(
174
+ description: str,
175
+ lang: Lang,
176
+ platform: Optional[Platform],
177
+ year_min: Optional[int],
178
+ year_max: Optional[int],
179
+ perspective: Optional[Perspective],
180
+ ) -> Dict[str, Any]:
181
+ args: Dict[str, Any] = {"description": description, "lang": lang}
182
+ for key, value in (
183
+ ("platform", platform),
184
+ ("year_min", year_min),
185
+ ("year_max", year_max),
186
+ ("perspective", perspective),
187
+ ):
188
+ if value is not None:
189
+ args[key] = value
190
+ return args
191
+
192
+
193
+ def _rpc(method: str, params: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
194
+ body: Dict[str, Any] = {"jsonrpc": "2.0", "id": next(_ids), "method": method}
195
+ if params is not None:
196
+ body["params"] = dict(params)
197
+ return body
198
+
199
+
200
+ def _tool_call(name: str, arguments: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
201
+ return _rpc("tools/call", {"name": name, "arguments": dict(arguments or {})})
202
+
203
+
204
+ _MCP_HEADERS = {
205
+ "Accept": "application/json, text/event-stream",
206
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
207
+ }
208
+
209
+
210
+ # ── response handling ──────────────────────────────────────────────────────
211
+
212
+
213
+ def _retry_after(resp: httpx.Response) -> Optional[float]:
214
+ value = resp.headers.get("Retry-After")
215
+ try:
216
+ return float(value) if value is not None else None
217
+ except ValueError:
218
+ return None
219
+
220
+
221
+ def _raise_for_rest(resp: httpx.Response) -> Dict[str, Any]:
222
+ try:
223
+ data = resp.json()
224
+ except ValueError:
225
+ data = None
226
+ if resp.status_code < 400 and isinstance(data, dict):
227
+ return data
228
+ detail = resp.text[:500] or resp.reason_phrase
229
+ code: Optional[str] = None
230
+ if isinstance(data, dict):
231
+ code = data.get("error") if isinstance(data.get("error"), str) else None
232
+ if isinstance(data.get("detail"), str):
233
+ detail = data["detail"]
234
+ elif data.get("detail") is not None: # FastAPI validation errors
235
+ detail = str(data["detail"])
236
+ status = resp.status_code
237
+ if status == 429 or code == "rate_limited":
238
+ raise RateLimitError(detail, status=status, retry_after=_retry_after(resp))
239
+ if status == 404 or code == "not_found":
240
+ raise NotFoundError(detail, code=code or "not_found", status=status)
241
+ if status in (400, 422) or code == "bad_request":
242
+ raise BadRequestError(detail, code=code or "bad_request", status=status)
243
+ if status == 503 or code == "disabled":
244
+ raise DisabledError(detail, code=code or "disabled", status=status)
245
+ raise ImhoError(detail, code=code, status=status)
246
+
247
+
248
+ def _rpc_result(resp: httpx.Response) -> Dict[str, Any]:
249
+ if resp.status_code == 429:
250
+ raise RateLimitError(resp.text[:500], retry_after=_retry_after(resp))
251
+ if resp.status_code >= 400:
252
+ raise ImhoError(resp.text[:500] or resp.reason_phrase, status=resp.status_code)
253
+ data = resp.json()
254
+ if "error" in data:
255
+ err = data["error"] or {}
256
+ raise BadRequestError(
257
+ str(err.get("message", "JSON-RPC error")),
258
+ code=str(err.get("code")),
259
+ status=resp.status_code,
260
+ )
261
+ return cast(Dict[str, Any], data.get("result") or {})
262
+
263
+
264
+ def _tool_payload(result: Mapping[str, Any]) -> Dict[str, Any]:
265
+ if result.get("isError"):
266
+ text = " ".join(
267
+ str(c.get("text", "")) for c in result.get("content") or [] if isinstance(c, Mapping)
268
+ ).strip()
269
+ match = _RATE_TEXT.search(text)
270
+ if match:
271
+ raise RateLimitError(text, retry_after=float(match.group(1)))
272
+ if text.startswith("No ") and "matched" in text:
273
+ raise NotFoundError(text, code="not_found")
274
+ if "switched off" in text:
275
+ raise DisabledError(text, code="disabled")
276
+ raise ToolError(text or "Tool call failed")
277
+ structured = result.get("structuredContent")
278
+ if isinstance(structured, dict):
279
+ return structured
280
+ # Fall back to the JSON text block.
281
+ for block in result.get("content") or []:
282
+ if isinstance(block, Mapping) and block.get("type") == "text":
283
+ try:
284
+ parsed = json.loads(block.get("text", ""))
285
+ except ValueError:
286
+ continue
287
+ if isinstance(parsed, dict):
288
+ return parsed
289
+ return {"content": list(result.get("content") or [])}
290
+
291
+
292
+ def _client_kwargs(
293
+ base_url: str, timeout: float, headers: Optional[Mapping[str, str]]
294
+ ) -> Dict[str, Any]:
295
+ merged = {"User-Agent": USER_AGENT}
296
+ if headers:
297
+ merged.update(headers)
298
+ return {"base_url": base_url.rstrip("/"), "timeout": timeout, "headers": merged}
299
+
300
+
301
+ def _timeout(timeout: Optional[float]) -> Any:
302
+ return timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT
303
+
304
+
305
+ # ── sync client ────────────────────────────────────────────────────────────
306
+
307
+
308
+ class ImhoClient:
309
+ """Blocking client.
310
+
311
+ >>> from imho import ImhoClient
312
+ >>> with ImhoClient() as imho:
313
+ ... picks = imho.games_like("Hollow Knight", n=5, coop=True)
314
+ """
315
+
316
+ def __init__(
317
+ self,
318
+ base_url: str = DEFAULT_BASE_URL,
319
+ *,
320
+ timeout: float = DEFAULT_TIMEOUT,
321
+ headers: Optional[Mapping[str, str]] = None,
322
+ http_client: Optional[httpx.Client] = None,
323
+ ) -> None:
324
+ self._owns_client = http_client is None
325
+ self._http = http_client or httpx.Client(**_client_kwargs(base_url, timeout, headers))
326
+
327
+ def __enter__(self) -> ImhoClient:
328
+ return self
329
+
330
+ def __exit__(self, *exc: object) -> None:
331
+ self.close()
332
+
333
+ def close(self) -> None:
334
+ if self._owns_client:
335
+ self._http.close()
336
+
337
+ def _get(self, request: Request) -> Dict[str, Any]:
338
+ path, params = request
339
+ return _raise_for_rest(self._http.get(path, params=tuple(params)))
340
+
341
+ def games_like(
342
+ self,
343
+ game: str,
344
+ *,
345
+ n: int = 10,
346
+ lang: Lang = "en",
347
+ free: bool = False,
348
+ coop: Coop = False,
349
+ steam_deck: Optional[SteamDeck] = None,
350
+ ) -> GamesLikeResult:
351
+ """Steam games similar to ``game`` (name, appid or Steam URL), ranked."""
352
+ req = _games_like_request(game, n, lang, free, coop, steam_deck)
353
+ return cast(GamesLikeResult, self._get(req))
354
+
355
+ def recommend(
356
+ self,
357
+ seeds: Sequence[str],
358
+ *,
359
+ n: int = 10,
360
+ lang: Lang = "en",
361
+ liked: Sequence[str] = (),
362
+ disliked: Sequence[str] = (),
363
+ preferences: Optional[str] = None,
364
+ free: bool = False,
365
+ coop: Coop = False,
366
+ steam_deck: Optional[SteamDeck] = None,
367
+ exclude: Sequence[Exclude] = (),
368
+ exclude_tags: Sequence[str] = (),
369
+ year_min: Optional[int] = None,
370
+ year_max: Optional[int] = None,
371
+ upcoming: bool = False,
372
+ popularity_bias: float = 0.0,
373
+ ) -> RecommendResult:
374
+ """Games for someone who likes 1-3 ``seeds``, with the full filter set.
375
+
376
+ ``preferences`` is free text ("cozy base building, no horror") read as
377
+ Steam tags. ``disliked`` games are never recommended.
378
+ """
379
+ req = _recommend_request(
380
+ seeds,
381
+ n=n,
382
+ lang=lang,
383
+ liked=liked,
384
+ disliked=disliked,
385
+ preferences=preferences,
386
+ free=free,
387
+ coop=coop,
388
+ steam_deck=steam_deck,
389
+ exclude=exclude,
390
+ exclude_tags=exclude_tags,
391
+ year_min=year_min,
392
+ year_max=year_max,
393
+ upcoming=upcoming,
394
+ popularity_bias=popularity_bias,
395
+ )
396
+ return cast(RecommendResult, self._get(req))
397
+
398
+ def game_facts(self, game: str, *, lang: Lang = "en") -> GameFactsResult:
399
+ """Public facts about one Steam game."""
400
+ return cast(GameFactsResult, self._get(_game_facts_request(game, lang)))
401
+
402
+ def trending(
403
+ self, *, kind: TrendingKind = "rising", n: int = 10, lang: Lang = "en"
404
+ ) -> TrendingResult:
405
+ """Steam games trending now: ``rising`` (established) or ``breakouts`` (new)."""
406
+ return cast(TrendingResult, self._get(_trending_request(kind, n, lang)))
407
+
408
+ def new_releases(
409
+ self, *, upcoming: bool = False, coop: bool = False, n: int = 10, lang: Lang = "en"
410
+ ) -> NewReleasesResult:
411
+ """Well-rated Steam releases of the last 30 days, or dated upcoming games."""
412
+ req = _new_releases_request(upcoming, coop, n, lang)
413
+ return cast(NewReleasesResult, self._get(req))
414
+
415
+ def search_games(self, query: str, *, n: int = 5) -> SearchResult:
416
+ """Steam games by title (typos, partial and Russian names are fine)."""
417
+ return cast(SearchResult, self._get(_search_request(query, n)))
418
+
419
+ def find_game_by_description(
420
+ self,
421
+ description: str,
422
+ *,
423
+ lang: Lang = "en",
424
+ platform: Optional[Platform] = None,
425
+ year_min: Optional[int] = None,
426
+ year_max: Optional[int] = None,
427
+ perspective: Optional[Perspective] = None,
428
+ ) -> FindGameResult:
429
+ """Identify a game from what someone remembers about it.
430
+
431
+ Slow (runs a language model) and limited to 3 calls a minute and 20 a
432
+ day per IP. Use it only when the title is unknown.
433
+ """
434
+ args = _find_args(description, lang, platform, year_min, year_max, perspective)
435
+ payload = self.call_tool("find_game_by_description", args, timeout=FIND_TIMEOUT)
436
+ return cast(FindGameResult, payload)
437
+
438
+ def call_tool(
439
+ self,
440
+ name: str,
441
+ arguments: Optional[Mapping[str, Any]] = None,
442
+ *,
443
+ timeout: Optional[float] = None,
444
+ ) -> Dict[str, Any]:
445
+ """Call any MCP tool by name and return its structured result."""
446
+ resp = self._http.post(
447
+ "/mcp",
448
+ json=_tool_call(name, arguments),
449
+ headers=_MCP_HEADERS,
450
+ timeout=_timeout(timeout),
451
+ )
452
+ return _tool_payload(_rpc_result(resp))
453
+
454
+ def list_tools(self) -> List[Dict[str, Any]]:
455
+ """The MCP server's tool definitions (name, description, input schema)."""
456
+ resp = self._http.post("/mcp", json=_rpc("tools/list"), headers=_MCP_HEADERS)
457
+ return cast(List[Dict[str, Any]], _rpc_result(resp).get("tools", []))
458
+
459
+
460
+ # ── async client ───────────────────────────────────────────────────────────
461
+
462
+
463
+ class AsyncImhoClient:
464
+ """Async client with the same methods as :class:`ImhoClient`.
465
+
466
+ >>> async with AsyncImhoClient() as imho:
467
+ ... facts = await imho.game_facts("Hades")
468
+ """
469
+
470
+ def __init__(
471
+ self,
472
+ base_url: str = DEFAULT_BASE_URL,
473
+ *,
474
+ timeout: float = DEFAULT_TIMEOUT,
475
+ headers: Optional[Mapping[str, str]] = None,
476
+ http_client: Optional[httpx.AsyncClient] = None,
477
+ ) -> None:
478
+ self._owns_client = http_client is None
479
+ self._http = http_client or httpx.AsyncClient(**_client_kwargs(base_url, timeout, headers))
480
+
481
+ async def __aenter__(self) -> AsyncImhoClient:
482
+ return self
483
+
484
+ async def __aexit__(self, *exc: object) -> None:
485
+ await self.aclose()
486
+
487
+ async def aclose(self) -> None:
488
+ if self._owns_client:
489
+ await self._http.aclose()
490
+
491
+ async def _get(self, request: Request) -> Dict[str, Any]:
492
+ path, params = request
493
+ return _raise_for_rest(await self._http.get(path, params=tuple(params)))
494
+
495
+ async def games_like(
496
+ self,
497
+ game: str,
498
+ *,
499
+ n: int = 10,
500
+ lang: Lang = "en",
501
+ free: bool = False,
502
+ coop: Coop = False,
503
+ steam_deck: Optional[SteamDeck] = None,
504
+ ) -> GamesLikeResult:
505
+ req = _games_like_request(game, n, lang, free, coop, steam_deck)
506
+ return cast(GamesLikeResult, await self._get(req))
507
+
508
+ async def recommend(
509
+ self,
510
+ seeds: Sequence[str],
511
+ *,
512
+ n: int = 10,
513
+ lang: Lang = "en",
514
+ liked: Sequence[str] = (),
515
+ disliked: Sequence[str] = (),
516
+ preferences: Optional[str] = None,
517
+ free: bool = False,
518
+ coop: Coop = False,
519
+ steam_deck: Optional[SteamDeck] = None,
520
+ exclude: Sequence[Exclude] = (),
521
+ exclude_tags: Sequence[str] = (),
522
+ year_min: Optional[int] = None,
523
+ year_max: Optional[int] = None,
524
+ upcoming: bool = False,
525
+ popularity_bias: float = 0.0,
526
+ ) -> RecommendResult:
527
+ req = _recommend_request(
528
+ seeds,
529
+ n=n,
530
+ lang=lang,
531
+ liked=liked,
532
+ disliked=disliked,
533
+ preferences=preferences,
534
+ free=free,
535
+ coop=coop,
536
+ steam_deck=steam_deck,
537
+ exclude=exclude,
538
+ exclude_tags=exclude_tags,
539
+ year_min=year_min,
540
+ year_max=year_max,
541
+ upcoming=upcoming,
542
+ popularity_bias=popularity_bias,
543
+ )
544
+ return cast(RecommendResult, await self._get(req))
545
+
546
+ async def game_facts(self, game: str, *, lang: Lang = "en") -> GameFactsResult:
547
+ return cast(GameFactsResult, await self._get(_game_facts_request(game, lang)))
548
+
549
+ async def trending(
550
+ self, *, kind: TrendingKind = "rising", n: int = 10, lang: Lang = "en"
551
+ ) -> TrendingResult:
552
+ return cast(TrendingResult, await self._get(_trending_request(kind, n, lang)))
553
+
554
+ async def new_releases(
555
+ self, *, upcoming: bool = False, coop: bool = False, n: int = 10, lang: Lang = "en"
556
+ ) -> NewReleasesResult:
557
+ req = _new_releases_request(upcoming, coop, n, lang)
558
+ return cast(NewReleasesResult, await self._get(req))
559
+
560
+ async def search_games(self, query: str, *, n: int = 5) -> SearchResult:
561
+ return cast(SearchResult, await self._get(_search_request(query, n)))
562
+
563
+ async def find_game_by_description(
564
+ self,
565
+ description: str,
566
+ *,
567
+ lang: Lang = "en",
568
+ platform: Optional[Platform] = None,
569
+ year_min: Optional[int] = None,
570
+ year_max: Optional[int] = None,
571
+ perspective: Optional[Perspective] = None,
572
+ ) -> FindGameResult:
573
+ args = _find_args(description, lang, platform, year_min, year_max, perspective)
574
+ payload = await self.call_tool("find_game_by_description", args, timeout=FIND_TIMEOUT)
575
+ return cast(FindGameResult, payload)
576
+
577
+ async def call_tool(
578
+ self,
579
+ name: str,
580
+ arguments: Optional[Mapping[str, Any]] = None,
581
+ *,
582
+ timeout: Optional[float] = None,
583
+ ) -> Dict[str, Any]:
584
+ resp = await self._http.post(
585
+ "/mcp",
586
+ json=_tool_call(name, arguments),
587
+ headers=_MCP_HEADERS,
588
+ timeout=_timeout(timeout),
589
+ )
590
+ return _tool_payload(_rpc_result(resp))
591
+
592
+ async def list_tools(self) -> List[Dict[str, Any]]:
593
+ resp = await self._http.post("/mcp", json=_rpc("tools/list"), headers=_MCP_HEADERS)
594
+ return cast(List[Dict[str, Any]], _rpc_result(resp).get("tools", []))
595
+
596
+
597
+ __all__ = ["DEFAULT_BASE_URL", "USER_AGENT", "AsyncImhoClient", "ImhoClient"]
imho/errors.py ADDED
@@ -0,0 +1,66 @@
1
+ """Exceptions raised by the imho client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class ImhoError(Exception):
9
+ """Any error answer from imho.run.
10
+
11
+ ``code`` is the API's error code when it sent one (``not_found``,
12
+ ``bad_request``, ``rate_limited``, ``disabled``), ``status`` the HTTP status.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ detail: str,
18
+ *,
19
+ code: Optional[str] = None,
20
+ status: Optional[int] = None,
21
+ ) -> None:
22
+ super().__init__(detail)
23
+ self.detail = detail
24
+ self.code = code
25
+ self.status = status
26
+
27
+
28
+ class NotFoundError(ImhoError):
29
+ """No game matched the query."""
30
+
31
+
32
+ class BadRequestError(ImhoError):
33
+ """The request was rejected (a parameter out of range, an empty query)."""
34
+
35
+
36
+ class RateLimitError(ImhoError):
37
+ """Too many requests. ``retry_after`` is in seconds when the server sent it."""
38
+
39
+ def __init__(
40
+ self,
41
+ detail: str,
42
+ *,
43
+ code: Optional[str] = "rate_limited",
44
+ status: Optional[int] = 429,
45
+ retry_after: Optional[float] = None,
46
+ ) -> None:
47
+ super().__init__(detail, code=code, status=status)
48
+ self.retry_after = retry_after
49
+
50
+
51
+ class DisabledError(ImhoError):
52
+ """The API is switched off on the server side for now."""
53
+
54
+
55
+ class ToolError(ImhoError):
56
+ """An MCP tool call returned ``isError: true``."""
57
+
58
+
59
+ __all__ = [
60
+ "BadRequestError",
61
+ "DisabledError",
62
+ "ImhoError",
63
+ "NotFoundError",
64
+ "RateLimitError",
65
+ "ToolError",
66
+ ]
imho/py.typed ADDED
File without changes
imho/types.py ADDED
@@ -0,0 +1,297 @@
1
+ """Response shapes of the imho.run agent API.
2
+
3
+ These are ``TypedDict``s: the client returns the decoded JSON as plain dicts,
4
+ and the types only describe them. Keys can be added on the server side at any
5
+ time, so code should ignore keys it does not know. Every key here is optional
6
+ (``total=False``) because some are omitted when the value is unknown.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Literal, Optional, TypedDict
12
+
13
+ Lang = Literal["en", "ru"]
14
+ SteamDeck = Literal["verified", "playable"]
15
+ CoopMode = Literal["online", "local"]
16
+ Platform = Literal["pc", "playstation", "xbox", "nintendo", "sega", "mobile", "browser", "arcade"]
17
+ Perspective = Literal["first", "third", "top_down", "side"]
18
+ Exclude = Literal["pvp", "microtransactions", "hard", "grind", "early_access", "vr_only"]
19
+ TrendingKind = Literal["rising", "breakouts"]
20
+
21
+
22
+ class Price(TypedDict, total=False):
23
+ is_free: bool
24
+ amount: Optional[float]
25
+ currency: Optional[str]
26
+ text: str
27
+
28
+
29
+ class Reviews(TypedDict, total=False):
30
+ total: int
31
+ positive_pct: Optional[int]
32
+
33
+
34
+ class GameRef(TypedDict, total=False):
35
+ appid: int
36
+ name: str
37
+ url: str
38
+ steam_url: str
39
+
40
+
41
+ class Filters(TypedDict, total=False):
42
+ free: bool
43
+ coop: bool
44
+ coop_mode: Optional[CoopMode]
45
+ deck: Optional[SteamDeck]
46
+
47
+
48
+ class Recommendation(TypedDict, total=False):
49
+ rank: int
50
+ appid: int
51
+ name: str
52
+ url: str
53
+ steam_url: str
54
+ why: str
55
+ year: Optional[int]
56
+ price: Price
57
+ steam_deck: Optional[str]
58
+ reviews: Reviews
59
+ genres: List[str]
60
+
61
+
62
+ class GamesLikeResult(TypedDict, total=False):
63
+ query: str
64
+ source: str
65
+ attribution: str
66
+ seed: GameRef
67
+ other_matches: List[GameRef]
68
+ lang: Lang
69
+ filters: Filters
70
+ list_url: str
71
+ results: List[Recommendation]
72
+ generated_at: str
73
+
74
+
75
+ class FactsSummary(TypedDict, total=False):
76
+ tagline: Optional[str]
77
+ difficulty: Optional[str]
78
+ length: Optional[str]
79
+ session_shape: Optional[str]
80
+ co_op: Optional[str]
81
+ hooks: List[str]
82
+ dealbreakers: List[str]
83
+ language: str
84
+
85
+
86
+ class CoopViaMod(TypedDict, total=False):
87
+ mod_name: str
88
+ mod_url: str
89
+ scope: str
90
+ scope_text: str
91
+ max_players: Optional[int]
92
+ maturity: str
93
+ status: str
94
+ last_checked: str
95
+ official_coop_is_summon_only: bool
96
+ note: str
97
+ note_lang: str
98
+ caveats: List[str]
99
+
100
+
101
+ class GameFacts(TypedDict, total=False):
102
+ appid: int
103
+ name: str
104
+ url: str
105
+ similar_url: str
106
+ steam_url: str
107
+ year: Optional[int]
108
+ release_date: Optional[str]
109
+ coming_soon: bool
110
+ developers: List[str]
111
+ genres: List[str]
112
+ tags: List[str]
113
+ price: Price
114
+ steam_deck: Optional[str]
115
+ reviews: Reviews
116
+ description: Optional[str]
117
+ description_lang: str
118
+ summary: FactsSummary
119
+ coop_via_mod: CoopViaMod
120
+
121
+
122
+ class GameFactsResult(TypedDict, total=False):
123
+ query: str
124
+ source: str
125
+ attribution: str
126
+ lang: Lang
127
+ game: GameFacts
128
+ other_matches: List[GameRef]
129
+ generated_at: str
130
+
131
+
132
+ class FoundGame(TypedDict, total=False):
133
+ rank: int
134
+ name: str
135
+ year: Optional[int]
136
+ platforms: List[str]
137
+ why: str
138
+ summary: Optional[str]
139
+ steam_appid: Optional[int]
140
+ url: Optional[str]
141
+ steam_url: Optional[str]
142
+ igdb_url: Optional[str]
143
+
144
+
145
+ class FindGameResult(TypedDict, total=False):
146
+ source: str
147
+ attribution: str
148
+ lang: Lang
149
+ confidence: str
150
+ results: List[FoundGame]
151
+ tool_url: str
152
+ find_game_url: str
153
+
154
+
155
+ # ── recommend ──────────────────────────────────────────────────────────────
156
+
157
+
158
+ class RecommendQuery(TypedDict, total=False):
159
+ seeds: List[str]
160
+ preferences: Optional[str]
161
+ liked: List[str]
162
+ disliked: List[str]
163
+
164
+
165
+ class RecommendFilters(TypedDict, total=False):
166
+ free: bool
167
+ coop: bool
168
+ coop_mode: Optional[str]
169
+ deck: Optional[SteamDeck]
170
+ exclude: List[str]
171
+ exclude_tags: List[str]
172
+ year_min: Optional[int]
173
+ year_max: Optional[int]
174
+ popularity_bias: float
175
+
176
+
177
+ class PreferencesRead(TypedDict, total=False):
178
+ """How the ``preferences`` text was read."""
179
+
180
+ applied: bool
181
+ prefer_tags: List[str]
182
+ avoid_tags: List[str]
183
+ filters: List[str]
184
+ unmatched: List[str]
185
+ suggestions: List[str]
186
+
187
+
188
+ class RecommendPick(Recommendation, total=False):
189
+ similar_to: Optional[int]
190
+ """Appid of the seed this pick is closest to."""
191
+
192
+
193
+ class RecommendResult(TypedDict, total=False):
194
+ query: RecommendQuery
195
+ source: str
196
+ attribution: str
197
+ seeds: List[GameRef]
198
+ lang: Lang
199
+ filters: RecommendFilters
200
+ preferred_tags: List[str]
201
+ tool_url: str
202
+ results: List[RecommendPick]
203
+ preferences_applied: bool
204
+ preferences: PreferencesRead
205
+ ignored: List[str]
206
+ generated_at: str
207
+
208
+
209
+ # ── trending / new releases / search ───────────────────────────────────────
210
+
211
+
212
+ class ListPick(TypedDict, total=False):
213
+ rank: int
214
+ appid: int
215
+ name: str
216
+ url: str
217
+ steam_url: str
218
+ year: Optional[int]
219
+ price: Price
220
+ steam_deck: str
221
+ reviews: Optional[Reviews]
222
+ genres: List[str]
223
+ reviews_week: int
224
+ """trending: Steam reviews in the last 7 days."""
225
+ reviews_growth_pct: int
226
+ """trending: weekly reviews vs the game's own 3-week baseline, in %."""
227
+ release_date: str
228
+ """new_releases: Steam release date."""
229
+
230
+
231
+ class TrendingResult(TypedDict, total=False):
232
+ source: str
233
+ attribution: str
234
+ lang: Lang
235
+ kind: TrendingKind
236
+ status: Literal["ready", "collecting"]
237
+ """``collecting``: the lists are not ready yet and ``results`` is empty."""
238
+ updated_at: Optional[str]
239
+ page_url: str
240
+ results: List[ListPick]
241
+
242
+
243
+ class NewReleasesResult(TypedDict, total=False):
244
+ source: str
245
+ attribution: str
246
+ lang: Lang
247
+ kind: Literal["released", "upcoming"]
248
+ coop: bool
249
+ page_url: str
250
+ results: List[ListPick]
251
+
252
+
253
+ class SearchHit(TypedDict, total=False):
254
+ appid: int
255
+ name: str
256
+ year: Optional[int]
257
+ url: str
258
+ steam_url: str
259
+
260
+
261
+ class SearchResult(TypedDict, total=False):
262
+ query: str
263
+ source: str
264
+ results: List[SearchHit]
265
+
266
+
267
+ __all__ = [
268
+ "CoopMode",
269
+ "CoopViaMod",
270
+ "Exclude",
271
+ "FactsSummary",
272
+ "Filters",
273
+ "FindGameResult",
274
+ "FoundGame",
275
+ "GameFacts",
276
+ "GameFactsResult",
277
+ "GameRef",
278
+ "GamesLikeResult",
279
+ "Lang",
280
+ "ListPick",
281
+ "NewReleasesResult",
282
+ "Perspective",
283
+ "Platform",
284
+ "PreferencesRead",
285
+ "Price",
286
+ "RecommendFilters",
287
+ "RecommendPick",
288
+ "RecommendQuery",
289
+ "RecommendResult",
290
+ "Recommendation",
291
+ "Reviews",
292
+ "SearchHit",
293
+ "SearchResult",
294
+ "SteamDeck",
295
+ "TrendingKind",
296
+ "TrendingResult",
297
+ ]
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.5
2
+ Name: imho
3
+ Version: 0.2.0
4
+ Summary: Python client for the imho.run API: Steam games like any game, game facts, and finding a game from a description.
5
+ Project-URL: Homepage, https://imho.run/developers
6
+ Project-URL: Documentation, https://github.com/0x216/imho-mcp/tree/main/python#readme
7
+ Project-URL: Repository, https://github.com/0x216/imho-mcp
8
+ Project-URL: Issues, https://github.com/0x216/imho-mcp/issues
9
+ Author-email: "imho.run" <admin@imho.run>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: game-recommendations,games,imho,mcp,recommender-system,steam
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Games/Entertainment
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<1,>=0.24
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
25
+ Requires-Dist: pytest>=7; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # imho (Python client for imho.run)
29
+
30
+ A small typed client for the [imho.run](https://imho.run) API for AI assistants:
31
+ Steam games like any game you name, recommendations from several games with
32
+ filters, facts about one game, what is trending and newly released on Steam,
33
+ title search, and identifying a game from a description. It wraps the public
34
+ REST endpoints under `https://imho.run/api/agent/` and the MCP endpoint
35
+ `https://imho.run/mcp`. The API is free, read-only and needs no key.
36
+
37
+ ```bash
38
+ pip install imho
39
+ ```
40
+
41
+ Requires Python 3.9+ and [httpx](https://www.python-httpx.org/). Source,
42
+ issues and the MCP setup for assistants: <https://github.com/0x216/imho-mcp>.
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from imho import ImhoClient
48
+
49
+ with ImhoClient() as imho:
50
+ picks = imho.games_like("Hollow Knight", n=3)
51
+ for game in picks["results"]:
52
+ print(game["rank"], game["name"], "-", game["why"], game["price"]["text"])
53
+ ```
54
+
55
+ ```
56
+ 1 Hollow Knight: Silksong - Also Metroidvania and Souls-like, like Hollow Knight. 19.99 USD
57
+ 2 Ori and the Blind Forest: Definitive Edition - Also Metroidvania, like Hollow Knight. 4.99 USD
58
+ 3 Nine Sols - Metroidvania with Sekiro-style parry combat and Taoist myth. 14.99 USD
59
+ ```
60
+
61
+ (Output from 2026-10-04. Rankings and prices change.)
62
+
63
+ ### games_like
64
+
65
+ ```python
66
+ imho.games_like(
67
+ "Stardew Valley", # name (typos, Russian titles OK), Steam appid or Steam store URL
68
+ n=10, # 1..20
69
+ lang="en", # "en" or "ru": language of the `why` lines
70
+ free=False, # only free-to-play games
71
+ coop=False, # True = any co-op, "online" or "local" (same screen / split screen)
72
+ steam_deck=None, # "verified" or "playable" (playable or better)
73
+ )
74
+ ```
75
+
76
+ Returns a dict with `seed` (the game that was matched), `other_matches`,
77
+ `results` (each with `rank`, `appid`, `name`, `url`, `steam_url`, `why`,
78
+ `year`, `price`, `steam_deck`, `reviews`, `genres`), `list_url` and
79
+ `attribution`.
80
+
81
+ ### recommend
82
+
83
+ For several games, more filters, or what the user wants in their own words:
84
+
85
+ ```python
86
+ recs = imho.recommend(
87
+ ["Stardew Valley", "Terraria"], # 1-3 seed games
88
+ n=10, # 1..24
89
+ preferences="cozy farming, no horror", # free text, read as Steam tags
90
+ coop=True, # also "online" / "local"
91
+ exclude=["pvp", "grind"], # pvp, microtransactions, hard, grind, early_access, vr_only
92
+ exclude_tags=["Anime"], # Steam tags to leave out
93
+ year_min=2015, year_max=None, # release years (year_max defaults to this year)
94
+ upcoming=False, # True: include unreleased games
95
+ popularity_bias=-0.5, # -1 more niche ... 1 more popular
96
+ free=False, steam_deck=None, lang="en",
97
+ )
98
+ for game in recs["results"]:
99
+ print(game["name"], "-", game["why"])
100
+ recs["preferences"]["prefer_tags"], recs["preferences"]["avoid_tags"]
101
+ # (['Farming Sim'], ['Horror'])
102
+ ```
103
+
104
+ To refine after the user reacts, call again with `liked=[...]` (fills free
105
+ seed slots, 3 seeds in all) and `disliked=[...]` (never recommended again).
106
+
107
+ ### trending, new_releases, search_games
108
+
109
+ ```python
110
+ imho.trending(kind="rising", n=10) # or kind="breakouts" for new games taking off
111
+ imho.new_releases(n=10) # well-rated releases of the last 30 days
112
+ imho.new_releases(upcoming=True, coop=True)
113
+ imho.search_games("hollow kn", n=5) # appid, year and links per match
114
+ ```
115
+
116
+ Trending picks carry `reviews_week` and `reviews_growth_pct`; new releases
117
+ carry `release_date`. While trending data is still being collected,
118
+ `trending()` returns `status: "collecting"` and an empty `results`.
119
+
120
+ ### game_facts
121
+
122
+ ```python
123
+ facts = imho.game_facts("Hades")["game"]
124
+ facts["summary"]["length"], facts["summary"]["difficulty"], facts["steam_deck"]
125
+ # ('massive', 'challenging', 'verified')
126
+ ```
127
+
128
+ Year, developers, genres, top tags, price, Steam Deck status, review numbers,
129
+ Steam's short description and, when imho.run has one, a summary mined from
130
+ player reviews (difficulty, length, session shape, co-op, hooks, dealbreakers).
131
+
132
+ ### find_game_by_description
133
+
134
+ ```python
135
+ hit = imho.find_game_by_description(
136
+ "you play a cat in a cyberpunk city with a little drone",
137
+ platform="pc", # pc, playstation, xbox, nintendo, sega, mobile, browser, arcade
138
+ year_min=None, year_max=None,
139
+ perspective=None, # first, third, top_down, side
140
+ )
141
+ hit["confidence"], hit["results"][0]["name"] # ('medium', 'Stray')
142
+ hit["find_game_url"] # imho.run/find-game with the description filled in
143
+ ```
144
+
145
+ This one runs a language model on the server. It takes several seconds and is
146
+ limited to 3 calls a minute and 20 a day per IP, so use it only when the title
147
+ is unknown.
148
+
149
+ ### Async
150
+
151
+ ```python
152
+ import asyncio
153
+ from imho import AsyncImhoClient
154
+
155
+ async def main() -> None:
156
+ async with AsyncImhoClient() as imho:
157
+ facts = await imho.game_facts("1145360")
158
+ print(facts["game"]["name"])
159
+
160
+ asyncio.run(main())
161
+ ```
162
+
163
+ ### Any MCP tool
164
+
165
+ `call_tool(name, arguments)` calls any tool on the MCP server and returns its
166
+ structured result, and `list_tools()` returns the tool definitions. Use them
167
+ for tools added to the server after this release.
168
+
169
+ ## Errors
170
+
171
+ | Exception | When |
172
+ | --- | --- |
173
+ | `NotFoundError` | No game matched the query (HTTP 404). |
174
+ | `BadRequestError` | A parameter is out of range or the query is empty (400/422), or an unknown MCP tool. |
175
+ | `RateLimitError` | Over the limit (HTTP 429). `retry_after` holds the seconds to wait when known. |
176
+ | `DisabledError` | The API is switched off on the server (503). |
177
+ | `ToolError` | An MCP tool returned `isError` for another reason. |
178
+
179
+ All of them subclass `ImhoError`, which has `detail`, `code` and `status`.
180
+
181
+ ## Limits and attribution
182
+
183
+ 30 requests a minute and 1,000 a day per IP; `find_game_by_description` 3 a
184
+ minute and 20 a day; `recommend` 10 new (uncached) combinations a minute and
185
+ 200 a day. Responses are cached on the server.
186
+
187
+ If you show the results to people, credit imho.run ("Recommendations by
188
+ imho.run") and link each game's `url`. Every response has an `attribution`
189
+ field with that wording.
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ cd python
195
+ uv run --with pytest --with pytest-asyncio --with-editable . pytest -q
196
+ IMHO_LIVE=1 uv run --with pytest --with pytest-asyncio --with-editable . pytest -q -m live # hits the real API
197
+ ```
198
+
199
+ ## License
200
+
201
+ MIT. The client is MIT-licensed; the imho.run service and its data are covered
202
+ by the [imho.run terms](https://imho.run/terms).
@@ -0,0 +1,10 @@
1
+ imho/__init__.py,sha256=MPGnnGDKnmVSb56nQncZ_N-J-BHfXNiWUoDxbCXwZkQ,730
2
+ imho/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
3
+ imho/client.py,sha256=37hKD_X-EbclTycFFsSYyF16l9rt0sP-gFQ8jnZfnkI,20487
4
+ imho/errors.py,sha256=tSsuXV_1u9YI-iEgw0PmMgBOs7ONAwm9-rH3a4Rn3Dw,1538
5
+ imho/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ imho/types.py,sha256=kKor2N05AXU6JzMa9L9yPqLOhBbNEC5r5Z5bf9Eu0gY,6810
7
+ imho-0.2.0.dist-info/METADATA,sha256=qmEGEjyLOk25Rw5YE7jHv0x4nJIbN23n-t8m2lDKbms,7655
8
+ imho-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ imho-0.2.0.dist-info/licenses/LICENSE,sha256=eQmqpNCbdOc_FxVecIZ-Com-kCqK1FjkXA3DfCUffnE,1065
10
+ imho-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 imho.run
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.