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 +31 -0
- imho/_version.py +1 -0
- imho/client.py +597 -0
- imho/errors.py +66 -0
- imho/py.typed +0 -0
- imho/types.py +297 -0
- imho-0.2.0.dist-info/METADATA +202 -0
- imho-0.2.0.dist-info/RECORD +10 -0
- imho-0.2.0.dist-info/WHEEL +4 -0
- imho-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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.
|