querais 0.2.0__tar.gz
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.
- querais-0.2.0/.gitignore +52 -0
- querais-0.2.0/PKG-INFO +106 -0
- querais-0.2.0/README.md +76 -0
- querais-0.2.0/pyproject.toml +59 -0
- querais-0.2.0/src/querais/__init__.py +12 -0
- querais-0.2.0/src/querais/client.py +201 -0
- querais-0.2.0/src/querais/langchain.py +30 -0
- querais-0.2.0/src/querais/llamaindex.py +32 -0
- querais-0.2.0/tests/test_client.py +127 -0
- querais-0.2.0/tests/test_integrations.py +27 -0
querais-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# build output
|
|
6
|
+
dist/
|
|
7
|
+
*.tsbuildinfo
|
|
8
|
+
|
|
9
|
+
# hardhat / contracts
|
|
10
|
+
packages/contracts/artifacts/
|
|
11
|
+
packages/contracts/cache/
|
|
12
|
+
packages/contracts/typechain-types/
|
|
13
|
+
packages/contracts/coverage/
|
|
14
|
+
packages/contracts/coverage.json
|
|
15
|
+
# Slither's symlink-free analysis copy (built fresh by the CI slither job)
|
|
16
|
+
packages/contracts/slither-scratch/
|
|
17
|
+
# Ephemeral local deployment only; real-network manifests (e.g. Sepolia) are committed.
|
|
18
|
+
packages/contracts/deployments/addresses.localhost.json
|
|
19
|
+
packages/contracts/src/abis.ts
|
|
20
|
+
|
|
21
|
+
# env & secrets
|
|
22
|
+
.env
|
|
23
|
+
.env.*
|
|
24
|
+
!.env.example
|
|
25
|
+
*.keystore
|
|
26
|
+
|
|
27
|
+
# logs
|
|
28
|
+
*.log
|
|
29
|
+
npm-debug.log*
|
|
30
|
+
pnpm-debug.log*
|
|
31
|
+
|
|
32
|
+
# editor / os
|
|
33
|
+
.DS_Store
|
|
34
|
+
.idea/
|
|
35
|
+
.vscode/*
|
|
36
|
+
!.vscode/extensions.json
|
|
37
|
+
|
|
38
|
+
# runtime data
|
|
39
|
+
data/
|
|
40
|
+
*.local.json
|
|
41
|
+
|
|
42
|
+
# release artifacts (built by scripts/bundle-daemon.mjs / release.yml)
|
|
43
|
+
# anchored: scripts/release/ (launcher sources) must stay tracked
|
|
44
|
+
/release/
|
|
45
|
+
|
|
46
|
+
# python (sdk-python)
|
|
47
|
+
.venv/
|
|
48
|
+
__pycache__/
|
|
49
|
+
*.egg-info/
|
|
50
|
+
.ruff_cache/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
sdk-python/dist/
|
querais-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: querais
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for QueraIS, the decentralized AI inference marketplace. OpenAI-shaped chat/streaming plus QueraIS extras: nodes, stats, model manifest.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ShavitR/querais/tree/main/sdk-python#readme
|
|
6
|
+
Project-URL: Repository, https://github.com/ShavitR/querais
|
|
7
|
+
Project-URL: Issues, https://github.com/ShavitR/querais/issues
|
|
8
|
+
Author: QueraIS contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai,arbitrum,decentralized,inference,llm,openai-compatible,querais,web3
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
25
|
+
Provides-Extra: langchain
|
|
26
|
+
Requires-Dist: langchain-openai>=0.2; extra == 'langchain'
|
|
27
|
+
Provides-Extra: llamaindex
|
|
28
|
+
Requires-Dist: llama-index-llms-openai-like>=0.3; extra == 'llamaindex'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# querais
|
|
32
|
+
|
|
33
|
+
Python client for [QueraIS](https://github.com/ShavitR/querais) — the decentralized
|
|
34
|
+
AI inference marketplace. Anyone with a GPU serves models and earns; you buy
|
|
35
|
+
inference through one OpenAI-compatible endpoint.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install querais
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from querais import QueraisClient
|
|
43
|
+
|
|
44
|
+
client = QueraisClient("https://querais-gateway.fly.dev", api_key="sk-...")
|
|
45
|
+
result = client.chat([{"role": "user", "content": "hello"}], model="llama3.2")
|
|
46
|
+
print(result.content)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## OpenAI-compatible
|
|
50
|
+
|
|
51
|
+
The gateway speaks the OpenAI chat-completions protocol, so the official `openai`
|
|
52
|
+
package works too — point it at the gateway:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from openai import OpenAI
|
|
56
|
+
|
|
57
|
+
client = OpenAI(base_url="https://querais-gateway.fly.dev/v1", api_key="sk-...")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This package is thin sugar over that protocol plus QueraIS-specific helpers.
|
|
61
|
+
|
|
62
|
+
## Streaming
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
for delta in client.chat_stream([{"role": "user", "content": "tell me a story"}],
|
|
66
|
+
model="llama3.2"):
|
|
67
|
+
print(delta, end="", flush=True)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## QueraIS extras
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
client.models() # model ids served by connected nodes
|
|
74
|
+
client.nodes() # public node directory: reputation, prices, dimensions
|
|
75
|
+
client.stats() # network stats
|
|
76
|
+
client.model_manifest() # the gateway's signed model-digest manifest (404 if unpinned)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Routing extensions on `chat()` / `chat_stream()`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
client.chat(messages, model="llama3.2",
|
|
83
|
+
max_price_per_1k_tokens=0.5, # cap what you pay
|
|
84
|
+
min_reputation=0.7) # floor the node quality
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## LangChain / LlamaIndex
|
|
88
|
+
|
|
89
|
+
The integrations return the **official** LangChain / LlamaIndex OpenAI classes
|
|
90
|
+
configured for the gateway — nothing reimplemented:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install 'querais[langchain]' # or 'querais[llamaindex]'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from querais.langchain import chat_model
|
|
98
|
+
llm = chat_model("https://querais-gateway.fly.dev", api_key="sk-...", model="llama3.2")
|
|
99
|
+
|
|
100
|
+
from querais.llamaindex import llm as qllm
|
|
101
|
+
llm = qllm("https://querais-gateway.fly.dev", api_key="sk-...", model="llama3.2")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
querais-0.2.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# querais
|
|
2
|
+
|
|
3
|
+
Python client for [QueraIS](https://github.com/ShavitR/querais) — the decentralized
|
|
4
|
+
AI inference marketplace. Anyone with a GPU serves models and earns; you buy
|
|
5
|
+
inference through one OpenAI-compatible endpoint.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install querais
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from querais import QueraisClient
|
|
13
|
+
|
|
14
|
+
client = QueraisClient("https://querais-gateway.fly.dev", api_key="sk-...")
|
|
15
|
+
result = client.chat([{"role": "user", "content": "hello"}], model="llama3.2")
|
|
16
|
+
print(result.content)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## OpenAI-compatible
|
|
20
|
+
|
|
21
|
+
The gateway speaks the OpenAI chat-completions protocol, so the official `openai`
|
|
22
|
+
package works too — point it at the gateway:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from openai import OpenAI
|
|
26
|
+
|
|
27
|
+
client = OpenAI(base_url="https://querais-gateway.fly.dev/v1", api_key="sk-...")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This package is thin sugar over that protocol plus QueraIS-specific helpers.
|
|
31
|
+
|
|
32
|
+
## Streaming
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
for delta in client.chat_stream([{"role": "user", "content": "tell me a story"}],
|
|
36
|
+
model="llama3.2"):
|
|
37
|
+
print(delta, end="", flush=True)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## QueraIS extras
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
client.models() # model ids served by connected nodes
|
|
44
|
+
client.nodes() # public node directory: reputation, prices, dimensions
|
|
45
|
+
client.stats() # network stats
|
|
46
|
+
client.model_manifest() # the gateway's signed model-digest manifest (404 if unpinned)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Routing extensions on `chat()` / `chat_stream()`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
client.chat(messages, model="llama3.2",
|
|
53
|
+
max_price_per_1k_tokens=0.5, # cap what you pay
|
|
54
|
+
min_reputation=0.7) # floor the node quality
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## LangChain / LlamaIndex
|
|
58
|
+
|
|
59
|
+
The integrations return the **official** LangChain / LlamaIndex OpenAI classes
|
|
60
|
+
configured for the gateway — nothing reimplemented:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install 'querais[langchain]' # or 'querais[llamaindex]'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from querais.langchain import chat_model
|
|
68
|
+
llm = chat_model("https://querais-gateway.fly.dev", api_key="sk-...", model="llama3.2")
|
|
69
|
+
|
|
70
|
+
from querais.llamaindex import llm as qllm
|
|
71
|
+
llm = qllm("https://querais-gateway.fly.dev", api_key="sk-...", model="llama3.2")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "querais"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python client for QueraIS, the decentralized AI inference marketplace. OpenAI-shaped chat/streaming plus QueraIS extras: nodes, stats, model manifest."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "QueraIS contributors" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"querais",
|
|
15
|
+
"ai",
|
|
16
|
+
"llm",
|
|
17
|
+
"inference",
|
|
18
|
+
"openai-compatible",
|
|
19
|
+
"decentralized",
|
|
20
|
+
"arbitrum",
|
|
21
|
+
"web3",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
]
|
|
33
|
+
dependencies = ["httpx>=0.27"]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
# Integrations return the OFFICIAL LangChain / LlamaIndex OpenAI classes pointed at
|
|
37
|
+
# the gateway — nothing is reimplemented, and `pip install querais` stays light.
|
|
38
|
+
langchain = ["langchain-openai>=0.2"]
|
|
39
|
+
llamaindex = ["llama-index-llms-openai-like>=0.3"]
|
|
40
|
+
dev = ["pytest>=8", "ruff>=0.8", "build>=1.2"]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/ShavitR/querais/tree/main/sdk-python#readme"
|
|
44
|
+
Repository = "https://github.com/ShavitR/querais"
|
|
45
|
+
Issues = "https://github.com/ShavitR/querais/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/querais"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py310"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
# Defaults (pyflakes + pycodestyle errors) plus import sorting, bugbear, pyupgrade.
|
|
56
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""QueraIS — Python client for the decentralized AI inference marketplace.
|
|
2
|
+
|
|
3
|
+
The gateway is OpenAI-compatible; this package is thin sugar plus QueraIS-specific
|
|
4
|
+
helpers. LangChain / LlamaIndex users: see ``querais.langchain`` /
|
|
5
|
+
``querais.llamaindex`` (optional extras).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import ChatResult, Message, QueraisClient, QueraisError
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["ChatResult", "Message", "QueraisClient", "QueraisError", "__version__"]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Thin, typed client for the QueraIS gateway.
|
|
2
|
+
|
|
3
|
+
The gateway is OpenAI-compatible, so this is convenience sugar (exactly like the
|
|
4
|
+
TypeScript SDK): chat + SSE streaming plus QueraIS-specific helpers (nodes, stats,
|
|
5
|
+
model manifest). The official ``openai`` package also works against the gateway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
Message = dict[str, str]
|
|
18
|
+
"""An OpenAI-shaped chat message: ``{"role": "user", "content": "..."}``."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueraisError(Exception):
|
|
22
|
+
"""A non-2xx response from the gateway (carries status + body text)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status: int, body: str):
|
|
25
|
+
super().__init__(f"QueraIS gateway returned HTTP {status}: {body}")
|
|
26
|
+
self.status = status
|
|
27
|
+
self.body = body
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ChatResult:
|
|
32
|
+
"""A buffered completion: the text, token usage, and the on-chain job id."""
|
|
33
|
+
|
|
34
|
+
content: str
|
|
35
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
36
|
+
job_id: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class QueraisClient:
|
|
40
|
+
"""Synchronous client for a QueraIS gateway.
|
|
41
|
+
|
|
42
|
+
>>> client = QueraisClient("https://querais-gateway.fly.dev", api_key="sk-...")
|
|
43
|
+
>>> result = client.chat([{"role": "user", "content": "hello"}], model="llama3.2")
|
|
44
|
+
>>> print(result.content)
|
|
45
|
+
|
|
46
|
+
``transport`` is injectable for tests (``httpx.MockTransport``) — no network,
|
|
47
|
+
no gateway needed.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str,
|
|
53
|
+
*,
|
|
54
|
+
api_key: str,
|
|
55
|
+
timeout: float = 120.0,
|
|
56
|
+
transport: httpx.BaseTransport | None = None,
|
|
57
|
+
):
|
|
58
|
+
self._http = httpx.Client(
|
|
59
|
+
base_url=base_url.rstrip("/"),
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
headers={"authorization": f"Bearer {api_key}"},
|
|
62
|
+
transport=transport,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ── lifecycle ──────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
self._http.close()
|
|
69
|
+
|
|
70
|
+
def __enter__(self) -> QueraisClient:
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def __exit__(self, *exc: object) -> None:
|
|
74
|
+
self.close()
|
|
75
|
+
|
|
76
|
+
# ── chat ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def _chat_body(
|
|
79
|
+
self,
|
|
80
|
+
messages: list[Message],
|
|
81
|
+
model: str,
|
|
82
|
+
stream: bool,
|
|
83
|
+
max_tokens: int | None,
|
|
84
|
+
temperature: float | None,
|
|
85
|
+
max_price_per_1k_tokens: float | None,
|
|
86
|
+
min_reputation: float | None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
body: dict[str, Any] = {"model": model, "messages": messages, "stream": stream}
|
|
89
|
+
if max_tokens is not None:
|
|
90
|
+
body["max_tokens"] = max_tokens
|
|
91
|
+
if temperature is not None:
|
|
92
|
+
body["temperature"] = temperature
|
|
93
|
+
# QueraIS routing extensions (ignored by other OpenAI-compatible servers).
|
|
94
|
+
if max_price_per_1k_tokens is not None:
|
|
95
|
+
body["max_price_per_1k_tokens"] = max_price_per_1k_tokens
|
|
96
|
+
if min_reputation is not None:
|
|
97
|
+
body["min_reputation"] = min_reputation
|
|
98
|
+
return body
|
|
99
|
+
|
|
100
|
+
def chat(
|
|
101
|
+
self,
|
|
102
|
+
messages: list[Message],
|
|
103
|
+
*,
|
|
104
|
+
model: str,
|
|
105
|
+
max_tokens: int | None = None,
|
|
106
|
+
temperature: float | None = None,
|
|
107
|
+
max_price_per_1k_tokens: float | None = None,
|
|
108
|
+
min_reputation: float | None = None,
|
|
109
|
+
) -> ChatResult:
|
|
110
|
+
"""Buffered chat completion."""
|
|
111
|
+
res = self._http.post(
|
|
112
|
+
"/v1/chat/completions",
|
|
113
|
+
json=self._chat_body(
|
|
114
|
+
messages,
|
|
115
|
+
model,
|
|
116
|
+
False,
|
|
117
|
+
max_tokens,
|
|
118
|
+
temperature,
|
|
119
|
+
max_price_per_1k_tokens,
|
|
120
|
+
min_reputation,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
if res.status_code >= 400:
|
|
124
|
+
raise QueraisError(res.status_code, res.text)
|
|
125
|
+
data = res.json()
|
|
126
|
+
choices = data.get("choices") or [{}]
|
|
127
|
+
return ChatResult(
|
|
128
|
+
content=(choices[0].get("message") or {}).get("content", ""),
|
|
129
|
+
usage=data.get("usage") or {},
|
|
130
|
+
job_id=res.headers.get("x-querais-job-id"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def chat_stream(
|
|
134
|
+
self,
|
|
135
|
+
messages: list[Message],
|
|
136
|
+
*,
|
|
137
|
+
model: str,
|
|
138
|
+
max_tokens: int | None = None,
|
|
139
|
+
temperature: float | None = None,
|
|
140
|
+
max_price_per_1k_tokens: float | None = None,
|
|
141
|
+
min_reputation: float | None = None,
|
|
142
|
+
) -> Iterator[str]:
|
|
143
|
+
"""Streaming chat completion — yields content deltas as they arrive (SSE)."""
|
|
144
|
+
with self._http.stream(
|
|
145
|
+
"POST",
|
|
146
|
+
"/v1/chat/completions",
|
|
147
|
+
json=self._chat_body(
|
|
148
|
+
messages,
|
|
149
|
+
model,
|
|
150
|
+
True,
|
|
151
|
+
max_tokens,
|
|
152
|
+
temperature,
|
|
153
|
+
max_price_per_1k_tokens,
|
|
154
|
+
min_reputation,
|
|
155
|
+
),
|
|
156
|
+
) as res:
|
|
157
|
+
if res.status_code >= 400:
|
|
158
|
+
res.read()
|
|
159
|
+
raise QueraisError(res.status_code, res.text)
|
|
160
|
+
for line in res.iter_lines():
|
|
161
|
+
if not line.startswith("data:"):
|
|
162
|
+
continue
|
|
163
|
+
data = line[len("data:") :].strip()
|
|
164
|
+
if not data or data == "[DONE]":
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
frame = json.loads(data)
|
|
168
|
+
except ValueError:
|
|
169
|
+
continue # keep-alives / non-JSON frames
|
|
170
|
+
delta = ((frame.get("choices") or [{}])[0].get("delta") or {}).get("content")
|
|
171
|
+
if delta:
|
|
172
|
+
yield delta
|
|
173
|
+
|
|
174
|
+
# ── QueraIS extras ─────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def models(self) -> list[str]:
|
|
177
|
+
"""Model ids currently served by at least one connected node."""
|
|
178
|
+
data = self._get_json("/v1/models")
|
|
179
|
+
return [m["id"] for m in data.get("data", [])]
|
|
180
|
+
|
|
181
|
+
def nodes(self) -> list[dict[str, Any]]:
|
|
182
|
+
"""The public node directory: wallet, reputation, offers, dimensions."""
|
|
183
|
+
return self._get_json("/v1/nodes").get("data", [])
|
|
184
|
+
|
|
185
|
+
def stats(self) -> dict[str, Any]:
|
|
186
|
+
"""Network stats (jobs, volume, nodes)."""
|
|
187
|
+
return self._get_json("/v1/stats")
|
|
188
|
+
|
|
189
|
+
def model_manifest(self) -> dict[str, Any]:
|
|
190
|
+
"""The gateway's signed model manifest (Slice 9). Raises 404 when unpinned.
|
|
191
|
+
|
|
192
|
+
The returned object is signed (EIP-191) by the gateway's settler address and
|
|
193
|
+
can be verified offline — see ``GET /v1/credit/info`` for the settler.
|
|
194
|
+
"""
|
|
195
|
+
return self._get_json("/v1/models/manifest")
|
|
196
|
+
|
|
197
|
+
def _get_json(self, path: str) -> dict[str, Any]:
|
|
198
|
+
res = self._http.get(path)
|
|
199
|
+
if res.status_code >= 400:
|
|
200
|
+
raise QueraisError(res.status_code, res.text)
|
|
201
|
+
return res.json()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""LangChain integration: the OFFICIAL ``ChatOpenAI`` pointed at a QueraIS gateway.
|
|
2
|
+
|
|
3
|
+
Nothing is reimplemented — the gateway speaks the OpenAI protocol, so the genuine
|
|
4
|
+
LangChain class works as-is. Requires the optional extra::
|
|
5
|
+
|
|
6
|
+
pip install 'querais[langchain]'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def chat_model(base_url: str, *, api_key: str, model: str, **kwargs: Any) -> Any:
|
|
15
|
+
"""A ``langchain_openai.ChatOpenAI`` configured for the gateway.
|
|
16
|
+
|
|
17
|
+
Extra ``kwargs`` (temperature, max_tokens, …) pass straight through.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
from langchain_openai import ChatOpenAI
|
|
21
|
+
except ImportError as err: # pragma: no cover - exercised via the extra
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"LangChain support needs the optional extra: pip install 'querais[langchain]'"
|
|
24
|
+
) from err
|
|
25
|
+
return ChatOpenAI(
|
|
26
|
+
base_url=f"{base_url.rstrip('/')}/v1",
|
|
27
|
+
api_key=api_key,
|
|
28
|
+
model=model,
|
|
29
|
+
**kwargs,
|
|
30
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""LlamaIndex integration: the OFFICIAL ``OpenAILike`` pointed at a QueraIS gateway.
|
|
2
|
+
|
|
3
|
+
Nothing is reimplemented — the gateway speaks the OpenAI protocol, so the genuine
|
|
4
|
+
LlamaIndex class works as-is. Requires the optional extra::
|
|
5
|
+
|
|
6
|
+
pip install 'querais[llamaindex]'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def llm(base_url: str, *, api_key: str, model: str, **kwargs: Any) -> Any:
|
|
15
|
+
"""A ``llama_index.llms.openai_like.OpenAILike`` configured for the gateway.
|
|
16
|
+
|
|
17
|
+
``is_chat_model`` defaults to True (the gateway serves chat completions).
|
|
18
|
+
Extra ``kwargs`` (temperature, max_tokens, …) pass straight through.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
from llama_index.llms.openai_like import OpenAILike
|
|
22
|
+
except ImportError as err: # pragma: no cover - exercised via the extra
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"LlamaIndex support needs the optional extra: pip install 'querais[llamaindex]'"
|
|
25
|
+
) from err
|
|
26
|
+
kwargs.setdefault("is_chat_model", True)
|
|
27
|
+
return OpenAILike(
|
|
28
|
+
api_base=f"{base_url.rstrip('/')}/v1",
|
|
29
|
+
api_key=api_key,
|
|
30
|
+
model=model,
|
|
31
|
+
**kwargs,
|
|
32
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""QueraisClient unit tests on httpx.MockTransport — no network, no gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from querais import ChatResult, QueraisClient, QueraisError
|
|
11
|
+
|
|
12
|
+
BASE = "https://gw.test"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_client(handler) -> QueraisClient:
|
|
16
|
+
return QueraisClient(BASE, api_key="sk-test", transport=httpx.MockTransport(handler))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_chat_returns_content_usage_and_job_id():
|
|
20
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
21
|
+
assert request.url.path == "/v1/chat/completions"
|
|
22
|
+
assert request.headers["authorization"] == "Bearer sk-test"
|
|
23
|
+
body = json.loads(request.content)
|
|
24
|
+
assert body["model"] == "mock-model"
|
|
25
|
+
assert body["stream"] is False
|
|
26
|
+
assert body["max_tokens"] == 32
|
|
27
|
+
assert body["max_price_per_1k_tokens"] == 0.5 # QueraIS routing extension
|
|
28
|
+
return httpx.Response(
|
|
29
|
+
200,
|
|
30
|
+
headers={"x-querais-job-id": "0xjob"},
|
|
31
|
+
json={
|
|
32
|
+
"choices": [{"message": {"role": "assistant", "content": "hello back"}}],
|
|
33
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
with make_client(handler) as client:
|
|
38
|
+
result = client.chat(
|
|
39
|
+
[{"role": "user", "content": "hello"}],
|
|
40
|
+
model="mock-model",
|
|
41
|
+
max_tokens=32,
|
|
42
|
+
max_price_per_1k_tokens=0.5,
|
|
43
|
+
)
|
|
44
|
+
assert result == ChatResult(
|
|
45
|
+
content="hello back",
|
|
46
|
+
usage={"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
47
|
+
job_id="0xjob",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_chat_raises_typed_error_with_status_and_body():
|
|
52
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
53
|
+
return httpx.Response(
|
|
54
|
+
503, json={"error": {"message": "no capacity", "type": "no_eligible_nodes"}}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with make_client(handler) as client:
|
|
58
|
+
with pytest.raises(QueraisError) as exc:
|
|
59
|
+
client.chat([{"role": "user", "content": "hi"}], model="mock-model")
|
|
60
|
+
assert exc.value.status == 503
|
|
61
|
+
assert "no_eligible_nodes" in exc.value.body
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_chat_stream_yields_deltas_and_skips_done_and_noise():
|
|
65
|
+
sse = (
|
|
66
|
+
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
|
|
67
|
+
"data: \n\n" # keep-alive
|
|
68
|
+
'data: {"choices":[{"delta":{}}]}\n\n' # finish frame, no content
|
|
69
|
+
'data: {"choices":[{"delta":{"content":"lo!"}}]}\n\n'
|
|
70
|
+
"data: [DONE]\n\n"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
74
|
+
assert json.loads(request.content)["stream"] is True
|
|
75
|
+
return httpx.Response(
|
|
76
|
+
200, content=sse.encode(), headers={"content-type": "text/event-stream"}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
with make_client(handler) as client:
|
|
80
|
+
chunks = list(client.chat_stream([{"role": "user", "content": "hi"}], model="mock-model"))
|
|
81
|
+
assert chunks == ["Hel", "lo!"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_chat_stream_raises_on_error_status():
|
|
85
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
86
|
+
return httpx.Response(401, json={"error": {"message": "bad key"}})
|
|
87
|
+
|
|
88
|
+
with make_client(handler) as client:
|
|
89
|
+
with pytest.raises(QueraisError) as exc:
|
|
90
|
+
list(client.chat_stream([{"role": "user", "content": "hi"}], model="mock-model"))
|
|
91
|
+
assert exc.value.status == 401
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_models_nodes_stats_and_manifest():
|
|
95
|
+
manifest = {
|
|
96
|
+
"models": {"llama3.2": {"digest": "sha256:" + "a" * 64}},
|
|
97
|
+
"signer": "0x" + "1" * 40,
|
|
98
|
+
"signature": "0x" + "2" * 130,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
102
|
+
routes = {
|
|
103
|
+
"/v1/models": {"object": "list", "data": [{"id": "llama3.2"}, {"id": "phi3"}]},
|
|
104
|
+
"/v1/nodes": {"data": [{"wallet": "0xabc", "reputation": 0.71}]},
|
|
105
|
+
"/v1/stats": {"jobs24h": 7, "nodes": 1},
|
|
106
|
+
"/v1/models/manifest": manifest,
|
|
107
|
+
}
|
|
108
|
+
payload = routes.get(request.url.path)
|
|
109
|
+
if payload is None:
|
|
110
|
+
return httpx.Response(404, json={"error": "not found"})
|
|
111
|
+
return httpx.Response(200, json=payload)
|
|
112
|
+
|
|
113
|
+
with make_client(handler) as client:
|
|
114
|
+
assert client.models() == ["llama3.2", "phi3"]
|
|
115
|
+
assert client.nodes() == [{"wallet": "0xabc", "reputation": 0.71}]
|
|
116
|
+
assert client.stats() == {"jobs24h": 7, "nodes": 1}
|
|
117
|
+
assert client.model_manifest() == manifest
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_model_manifest_404_when_gateway_unpinned():
|
|
121
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
122
|
+
return httpx.Response(404, json={"error": "no model manifest configured"})
|
|
123
|
+
|
|
124
|
+
with make_client(handler) as client:
|
|
125
|
+
with pytest.raises(QueraisError) as exc:
|
|
126
|
+
client.model_manifest()
|
|
127
|
+
assert exc.value.status == 404
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Integration-module tests — skip cleanly when the optional extra isn't installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_langchain_chat_model_points_at_the_gateway():
|
|
9
|
+
pytest.importorskip("langchain_openai")
|
|
10
|
+
from querais.langchain import chat_model
|
|
11
|
+
|
|
12
|
+
llm = chat_model("https://gw.test/", api_key="sk-test", model="llama3.2", temperature=0)
|
|
13
|
+
# The official class, configured for the gateway (trailing slash normalized).
|
|
14
|
+
assert type(llm).__name__ == "ChatOpenAI"
|
|
15
|
+
assert llm.openai_api_base == "https://gw.test/v1"
|
|
16
|
+
assert llm.model_name == "llama3.2"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_llamaindex_llm_points_at_the_gateway():
|
|
20
|
+
pytest.importorskip("llama_index.llms.openai_like")
|
|
21
|
+
from querais.llamaindex import llm
|
|
22
|
+
|
|
23
|
+
model = llm("https://gw.test", api_key="sk-test", model="llama3.2")
|
|
24
|
+
assert type(model).__name__ == "OpenAILike"
|
|
25
|
+
assert model.api_base == "https://gw.test/v1"
|
|
26
|
+
assert model.model == "llama3.2"
|
|
27
|
+
assert model.is_chat_model is True
|