thinkless 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.
- thinkless/__init__.py +63 -0
- thinkless/__main__.py +3 -0
- thinkless/_hub.py +84 -0
- thinkless/_json.py +33 -0
- thinkless/_version.py +1 -0
- thinkless/bench/__init__.py +22 -0
- thinkless/bench/intents.py +406 -0
- thinkless/bench/intents_report.py +87 -0
- thinkless/bench/metrics.py +115 -0
- thinkless/bench/report.py +123 -0
- thinkless/bench/support.py +342 -0
- thinkless/cli/__init__.py +1 -0
- thinkless/cli/main.py +595 -0
- thinkless/confidence.py +62 -0
- thinkless/data/pricing.toml +56 -0
- thinkless/data/viewer.html +411 -0
- thinkless/decision.py +185 -0
- thinkless/demo/__init__.py +1 -0
- thinkless/demo/support/__init__.py +19 -0
- thinkless/demo/support/agent.py +379 -0
- thinkless/demo/support/data/calibration.jsonl +48 -0
- thinkless/demo/support/data/scenarios.jsonl +53 -0
- thinkless/demo/support/data/world.json +78 -0
- thinkless/demo/support/questions.py +118 -0
- thinkless/demo/support/stack.py +93 -0
- thinkless/demo/support/world.py +165 -0
- thinkless/engine.py +605 -0
- thinkless/errors.py +13 -0
- thinkless/llm/__init__.py +47 -0
- thinkless/llm/anthropic.py +126 -0
- thinkless/llm/base.py +82 -0
- thinkless/llm/factory.py +91 -0
- thinkless/llm/local.py +168 -0
- thinkless/llm/openai_compat.py +165 -0
- thinkless/llm/openrouter.py +78 -0
- thinkless/llm/scripted.py +75 -0
- thinkless/logs.py +68 -0
- thinkless/pricing.py +100 -0
- thinkless/providers/__init__.py +43 -0
- thinkless/providers/base.py +108 -0
- thinkless/providers/gliner.py +194 -0
- thinkless/providers/hf.py +161 -0
- thinkless/providers/laya.py +121 -0
- thinkless/providers/llm.py +305 -0
- thinkless/providers/rules.py +181 -0
- thinkless/providers/systemone.py +147 -0
- thinkless/providers/wire.py +108 -0
- thinkless/py.typed +0 -0
- thinkless/questions.py +246 -0
- thinkless/settings.py +93 -0
- thinkless/tracing/__init__.py +23 -0
- thinkless/tracing/console.py +169 -0
- thinkless/tracing/otel.py +139 -0
- thinkless/tracing/sinks.py +142 -0
- thinkless/tracing/span.py +98 -0
- thinkless/tracing/summary.py +143 -0
- thinkless/tracing/tracer.py +159 -0
- thinkless/tracing/viewer.py +87 -0
- thinkless-0.2.0.dist-info/METADATA +357 -0
- thinkless-0.2.0.dist-info/RECORD +63 -0
- thinkless-0.2.0.dist-info/WHEEL +4 -0
- thinkless-0.2.0.dist-info/entry_points.txt +2 -0
- thinkless-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""OpenRouter: hundreds of models from every major lab behind one API key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .openai_compat import OpenAICompatibleLLM
|
|
10
|
+
|
|
11
|
+
__all__ = ["OPENROUTER_URL", "OpenRouterLLM"]
|
|
12
|
+
|
|
13
|
+
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
|
14
|
+
PROJECT_URL = "https://github.com/inboxpraveen/ThinkLess"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenRouterLLM(OpenAICompatibleLLM):
|
|
18
|
+
"""Models served through OpenRouter.
|
|
19
|
+
|
|
20
|
+
OpenRouter routes one OpenAI-compatible API to models from Anthropic,
|
|
21
|
+
OpenAI, Google, Qwen, DeepSeek, Mistral, Meta and others, so a single key
|
|
22
|
+
is enough to compare reasoning models. It reports the billed cost of every
|
|
23
|
+
call, which ThinkLess records in traces instead of an estimate.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
model: OpenRouter model slug, for example ``qwen/qwen3.7-flash`` or
|
|
27
|
+
``anthropic/claude-haiku-4.5``.
|
|
28
|
+
api_key: Defaults to ``OPENROUTER_API_KEY``.
|
|
29
|
+
reasoning: OpenRouter's ``reasoning`` options, for example
|
|
30
|
+
``{"effort": "low"}`` or ``{"enabled": False}``. Left unset, each
|
|
31
|
+
model uses its own default. Short decisions and replies rarely
|
|
32
|
+
need long reasoning, and reasoning tokens are billed as output.
|
|
33
|
+
provider_preferences: OpenRouter's ``provider`` routing options, for
|
|
34
|
+
example ``{"sort": "price"}`` or ``{"require_parameters": True}``.
|
|
35
|
+
app_name, app_url: Attribution headers OpenRouter shows in its
|
|
36
|
+
dashboards. Set either to ``None`` to omit it.
|
|
37
|
+
**kwargs: Passed to :class:`OpenAICompatibleLLM`.
|
|
38
|
+
|
|
39
|
+
Requires the ``openai`` extra.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
model: str,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
reasoning: Mapping[str, Any] | None = None,
|
|
48
|
+
provider_preferences: Mapping[str, Any] | None = None,
|
|
49
|
+
app_name: str | None = "ThinkLess",
|
|
50
|
+
app_url: str | None = PROJECT_URL,
|
|
51
|
+
**kwargs: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
key = api_key or os.environ.get("OPENROUTER_API_KEY")
|
|
54
|
+
if not key and kwargs.get("client") is None:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"OpenRouter needs an API key: set OPENROUTER_API_KEY (a .env file works "
|
|
57
|
+
"with the CLI) or pass api_key="
|
|
58
|
+
)
|
|
59
|
+
headers: dict[str, str] = {}
|
|
60
|
+
if app_url:
|
|
61
|
+
headers["HTTP-Referer"] = app_url
|
|
62
|
+
if app_name:
|
|
63
|
+
headers["X-Title"] = app_name
|
|
64
|
+
extra: dict[str, Any] = dict(kwargs.pop("extra_body", None) or {})
|
|
65
|
+
if reasoning is not None:
|
|
66
|
+
extra["reasoning"] = dict(reasoning)
|
|
67
|
+
if provider_preferences is not None:
|
|
68
|
+
extra["provider"] = dict(provider_preferences)
|
|
69
|
+
super().__init__(
|
|
70
|
+
model,
|
|
71
|
+
base_url=OPENROUTER_URL,
|
|
72
|
+
api_key=key,
|
|
73
|
+
provider="openrouter",
|
|
74
|
+
token_param=kwargs.pop("token_param", "max_tokens"),
|
|
75
|
+
default_headers=headers,
|
|
76
|
+
extra_body=extra,
|
|
77
|
+
**kwargs,
|
|
78
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A deterministic LLM for tests, examples and dry runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
|
|
8
|
+
from ..decision import Usage
|
|
9
|
+
from .base import LLM, Completion, Message
|
|
10
|
+
|
|
11
|
+
__all__ = ["ScriptedLLM"]
|
|
12
|
+
|
|
13
|
+
Responder = Callable[[Sequence[Message], "str | None"], str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _approx_tokens(text: str) -> int:
|
|
17
|
+
# Roughly four characters per token for English text.
|
|
18
|
+
return max(1, len(text) // 4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ScriptedLLM(LLM):
|
|
22
|
+
"""Returns canned responses.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
responses: Either a list of strings returned in order (the last one
|
|
26
|
+
repeats), or a callable ``(messages, system) -> str``.
|
|
27
|
+
model: Name reported in traces.
|
|
28
|
+
latency_ms: Artificial delay, to make dry-run traces look realistic.
|
|
29
|
+
|
|
30
|
+
Token usage is approximated from character counts.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
provider = "scripted"
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
responses: Sequence[str] | Responder,
|
|
38
|
+
*,
|
|
39
|
+
model: str = "scripted",
|
|
40
|
+
latency_ms: float = 0.0,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.model = model
|
|
43
|
+
self._responses = responses
|
|
44
|
+
self._index = 0
|
|
45
|
+
self._latency_ms = latency_ms
|
|
46
|
+
self.calls: list[tuple[list[Message], str | None]] = []
|
|
47
|
+
|
|
48
|
+
def complete(
|
|
49
|
+
self,
|
|
50
|
+
messages: Sequence[Message],
|
|
51
|
+
*,
|
|
52
|
+
system: str | None = None,
|
|
53
|
+
max_tokens: int = 512,
|
|
54
|
+
temperature: float | None = None,
|
|
55
|
+
json_mode: bool = False,
|
|
56
|
+
) -> Completion:
|
|
57
|
+
started = time.perf_counter()
|
|
58
|
+
self.calls.append((list(messages), system))
|
|
59
|
+
if callable(self._responses):
|
|
60
|
+
text = self._responses(messages, system)
|
|
61
|
+
else:
|
|
62
|
+
if not self._responses:
|
|
63
|
+
raise ValueError("ScriptedLLM has no responses")
|
|
64
|
+
text = self._responses[min(self._index, len(self._responses) - 1)]
|
|
65
|
+
self._index += 1
|
|
66
|
+
if self._latency_ms:
|
|
67
|
+
time.sleep(self._latency_ms / 1000.0)
|
|
68
|
+
prompt = (system or "") + "".join(m.get("content", "") for m in messages)
|
|
69
|
+
return Completion(
|
|
70
|
+
text=text,
|
|
71
|
+
model=self.model,
|
|
72
|
+
usage=Usage(input_tokens=_approx_tokens(prompt), output_tokens=_approx_tokens(text)),
|
|
73
|
+
latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
74
|
+
stop_reason="end_turn",
|
|
75
|
+
)
|
thinkless/logs.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Logging setup.
|
|
2
|
+
|
|
3
|
+
The library only ever logs through the ``thinkless`` logger and installs a
|
|
4
|
+
``NullHandler``, so it stays silent unless the application configures logging.
|
|
5
|
+
:func:`configure_logging` is a convenience for scripts and the CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["JSONFormatter", "configure_logging", "get_logger"]
|
|
17
|
+
|
|
18
|
+
logging.getLogger("thinkless").addHandler(logging.NullHandler())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_logger(name: str) -> logging.Logger:
|
|
22
|
+
"""A child of the ``thinkless`` logger."""
|
|
23
|
+
return logging.getLogger(name if name.startswith("thinkless") else f"thinkless.{name}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JSONFormatter(logging.Formatter):
|
|
27
|
+
"""One JSON object per line, for log shippers."""
|
|
28
|
+
|
|
29
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
30
|
+
payload: dict[str, Any] = {
|
|
31
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
|
|
32
|
+
+ f".{int(record.msecs):03d}Z",
|
|
33
|
+
"level": record.levelname.lower(),
|
|
34
|
+
"logger": record.name,
|
|
35
|
+
"message": record.getMessage(),
|
|
36
|
+
}
|
|
37
|
+
if record.exc_info:
|
|
38
|
+
payload["exc_info"] = self.formatException(record.exc_info)
|
|
39
|
+
for key in ("trace_id", "span_id", "provider", "question"):
|
|
40
|
+
value = getattr(record, key, None)
|
|
41
|
+
if value is not None:
|
|
42
|
+
payload[key] = value
|
|
43
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def configure_logging(level: str | int = "INFO", *, json_format: bool = False) -> None:
|
|
47
|
+
"""Attach a handler to the ``thinkless`` logger.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
level: Log level name or number.
|
|
51
|
+
json_format: Emit JSON lines instead of Rich-formatted text.
|
|
52
|
+
"""
|
|
53
|
+
logger = logging.getLogger("thinkless")
|
|
54
|
+
for existing in list(logger.handlers):
|
|
55
|
+
if not isinstance(existing, logging.NullHandler):
|
|
56
|
+
logger.removeHandler(existing)
|
|
57
|
+
handler: logging.Handler
|
|
58
|
+
if json_format:
|
|
59
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
60
|
+
handler.setFormatter(JSONFormatter())
|
|
61
|
+
else:
|
|
62
|
+
from rich.logging import RichHandler
|
|
63
|
+
|
|
64
|
+
handler = RichHandler(show_path=False, rich_tracebacks=True, markup=False)
|
|
65
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
66
|
+
logger.addHandler(handler)
|
|
67
|
+
logger.setLevel(level if isinstance(level, int) else level.upper())
|
|
68
|
+
logger.propagate = False
|
thinkless/pricing.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Cost estimation from a price table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .decision import Usage
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 11):
|
|
17
|
+
import tomllib
|
|
18
|
+
else: # pragma: no cover
|
|
19
|
+
import tomli as tomllib
|
|
20
|
+
|
|
21
|
+
__all__ = ["Price", "PriceTable", "default_prices"]
|
|
22
|
+
|
|
23
|
+
LOCAL_PROVIDERS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"local",
|
|
26
|
+
"transformers",
|
|
27
|
+
"rules",
|
|
28
|
+
"laya",
|
|
29
|
+
"gliner",
|
|
30
|
+
"scripted",
|
|
31
|
+
"ollama",
|
|
32
|
+
"vllm",
|
|
33
|
+
"lmstudio",
|
|
34
|
+
"llamacpp",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Price(BaseModel):
|
|
40
|
+
provider: str
|
|
41
|
+
model: str
|
|
42
|
+
input: float
|
|
43
|
+
output: float
|
|
44
|
+
match: str = "exact"
|
|
45
|
+
source: str | None = None
|
|
46
|
+
|
|
47
|
+
def matches(self, provider: str, model: str) -> bool:
|
|
48
|
+
if provider != self.provider:
|
|
49
|
+
return False
|
|
50
|
+
if self.match == "prefix":
|
|
51
|
+
return model.startswith(self.model)
|
|
52
|
+
return model == self.model
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PriceTable:
|
|
56
|
+
"""Looks up per-token prices and turns usage into dollars."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, prices: list[Price], as_of: str | None = None) -> None:
|
|
59
|
+
# Longest model id first so a specific entry wins over a prefix.
|
|
60
|
+
self.prices = sorted(prices, key=lambda p: len(p.model), reverse=True)
|
|
61
|
+
self.as_of = as_of
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_toml(cls, text: str) -> PriceTable:
|
|
65
|
+
data: dict[str, Any] = tomllib.loads(text)
|
|
66
|
+
return cls([Price(**entry) for entry in data.get("price", [])], data.get("as_of"))
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def load(cls, path: str | Path | None = None) -> PriceTable:
|
|
70
|
+
"""Load a price table from ``path``, ``$THINKLESS_PRICING`` or the bundled file."""
|
|
71
|
+
target = path or os.environ.get("THINKLESS_PRICING")
|
|
72
|
+
if target:
|
|
73
|
+
return cls.from_toml(Path(target).read_text(encoding="utf-8"))
|
|
74
|
+
bundled = resources.files("thinkless").joinpath("data/pricing.toml")
|
|
75
|
+
return cls.from_toml(bundled.read_text(encoding="utf-8"))
|
|
76
|
+
|
|
77
|
+
def find(self, provider: str, model: str) -> Price | None:
|
|
78
|
+
for price in self.prices:
|
|
79
|
+
if price.matches(provider, model):
|
|
80
|
+
return price
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def cost(self, provider: str, model: str | None, usage: Usage) -> tuple[float, bool]:
|
|
84
|
+
"""Dollar cost of ``usage`` and whether the price was known.
|
|
85
|
+
|
|
86
|
+
Local providers always cost 0 and count as known.
|
|
87
|
+
"""
|
|
88
|
+
if provider in LOCAL_PROVIDERS:
|
|
89
|
+
return 0.0, True
|
|
90
|
+
price = self.find(provider, model or "")
|
|
91
|
+
if price is None:
|
|
92
|
+
return 0.0, False
|
|
93
|
+
dollars = (usage.input_tokens * price.input + usage.output_tokens * price.output) / 1e6
|
|
94
|
+
return dollars, True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=1)
|
|
98
|
+
def default_prices() -> PriceTable:
|
|
99
|
+
"""The process-wide price table (cached)."""
|
|
100
|
+
return PriceTable.load()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Decision providers.
|
|
2
|
+
|
|
3
|
+
Local model providers are imported lazily, so the core package does not pull
|
|
4
|
+
in torch or model libraries until one is used.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from .base import DecisionProvider, ProviderResult, State, render_state
|
|
12
|
+
from .llm import LLMDecider
|
|
13
|
+
from .rules import Rules
|
|
14
|
+
from .systemone import SystemOne, SystemOneError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .gliner import GLiNER
|
|
18
|
+
from .hf import HFClassifier
|
|
19
|
+
from .laya import Laya
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DecisionProvider",
|
|
23
|
+
"GLiNER",
|
|
24
|
+
"HFClassifier",
|
|
25
|
+
"LLMDecider",
|
|
26
|
+
"Laya",
|
|
27
|
+
"ProviderResult",
|
|
28
|
+
"Rules",
|
|
29
|
+
"State",
|
|
30
|
+
"SystemOne",
|
|
31
|
+
"SystemOneError",
|
|
32
|
+
"render_state",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_LAZY = {"GLiNER": ".gliner", "HFClassifier": ".hf", "Laya": ".laya"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name: str) -> Any:
|
|
39
|
+
if name in _LAZY:
|
|
40
|
+
from importlib import import_module
|
|
41
|
+
|
|
42
|
+
return getattr(import_module(_LAZY[name], __name__), name)
|
|
43
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The decision provider interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from ..decision import Answer, Plane, Usage
|
|
13
|
+
from ..questions import Kind, Question
|
|
14
|
+
|
|
15
|
+
__all__ = ["DecisionProvider", "ProviderResult", "State", "render_state"]
|
|
16
|
+
|
|
17
|
+
State = str | Mapping[str, Any] | Sequence[Any]
|
|
18
|
+
"""What a question is asked about: text, a JSON-like object, or a list of either."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_state(state: State) -> str:
|
|
22
|
+
"""Flatten a state into text for providers that only read strings.
|
|
23
|
+
|
|
24
|
+
Mappings become ``key: value`` lines (nested values as compact JSON) so
|
|
25
|
+
field names stay visible to the model.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(state, str):
|
|
28
|
+
return state
|
|
29
|
+
if isinstance(state, Mapping):
|
|
30
|
+
lines = []
|
|
31
|
+
for key, value in state.items():
|
|
32
|
+
if value is None or value == "":
|
|
33
|
+
continue
|
|
34
|
+
text = (
|
|
35
|
+
value
|
|
36
|
+
if isinstance(value, str)
|
|
37
|
+
else json.dumps(value, ensure_ascii=False, default=str)
|
|
38
|
+
)
|
|
39
|
+
lines.append(f"{key}: {text}")
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
if isinstance(state, Sequence):
|
|
42
|
+
return "\n".join(render_state(item) for item in state)
|
|
43
|
+
return str(state)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProviderResult(BaseModel):
|
|
47
|
+
"""What a provider returns for one batched call.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
answers: One entry per question asked. ``None`` means the provider
|
|
51
|
+
abstained on that question.
|
|
52
|
+
model: The model that served the call, as reported by the backend.
|
|
53
|
+
usage: Tokens consumed by the call.
|
|
54
|
+
cost_usd: Cost reported by the backend for this call. When ``None``,
|
|
55
|
+
the engine estimates it from the price table.
|
|
56
|
+
meta: Structural details recorded on the trace span.
|
|
57
|
+
content: Prompts and raw completions; recorded only when content
|
|
58
|
+
capture is on.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
answers: dict[str, Answer | None]
|
|
64
|
+
model: str | None = None
|
|
65
|
+
usage: Usage = Field(default_factory=Usage)
|
|
66
|
+
cost_usd: float | None = None
|
|
67
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
68
|
+
content: dict[str, Any] = Field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DecisionProvider(ABC):
|
|
72
|
+
"""Answers typed questions.
|
|
73
|
+
|
|
74
|
+
Subclasses set:
|
|
75
|
+
name: Unique name within an engine, used in traces and allowlists.
|
|
76
|
+
plane: ``rule``, ``model`` or ``llm``.
|
|
77
|
+
kinds: Question kinds the provider can answer.
|
|
78
|
+
calibrated: Whether its probabilities are meaningful enough to
|
|
79
|
+
threshold. Prompted LLMs are not.
|
|
80
|
+
accepts_context: Whether ``answer`` takes a ``context`` keyword with
|
|
81
|
+
the decisions already settled in the same batch. The engine only
|
|
82
|
+
passes it to providers that set this.
|
|
83
|
+
price_key: Provider id used for price lookup.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
name: str = "provider"
|
|
87
|
+
plane: ClassVar[Plane] = Plane.MODEL
|
|
88
|
+
kinds: ClassVar[frozenset[Kind]] = frozenset()
|
|
89
|
+
calibrated: ClassVar[bool] = True
|
|
90
|
+
accepts_context: ClassVar[bool] = False
|
|
91
|
+
price_key: str = "local"
|
|
92
|
+
|
|
93
|
+
def supports(self, question: Question) -> bool:
|
|
94
|
+
return question.kind in self.kinds and question.allows(self.name)
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def answer(self, state: State, questions: Mapping[str, Question]) -> ProviderResult:
|
|
98
|
+
"""Answer every question in one call where the backend allows it."""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def warmup(self) -> None: # noqa: B027 - optional hook
|
|
102
|
+
"""Load weights ahead of the first call."""
|
|
103
|
+
|
|
104
|
+
def close(self) -> None: # noqa: B027 - optional hook
|
|
105
|
+
"""Release resources."""
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
return f"{type(self).__name__}(name={self.name!r})"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""GLiNER 2.5: schema-driven classification and extraction on small encoders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from .._hub import ensure_downloaded
|
|
11
|
+
from ..confidence import normalize_distribution
|
|
12
|
+
from ..decision import Answer, Plane
|
|
13
|
+
from ..logs import get_logger
|
|
14
|
+
from ..questions import Choice, Extract, Kind, Question
|
|
15
|
+
from ..settings import resolve_device
|
|
16
|
+
from .base import DecisionProvider, ProviderResult, State, render_state
|
|
17
|
+
|
|
18
|
+
__all__ = ["GLiNER"]
|
|
19
|
+
|
|
20
|
+
logger = get_logger("providers.gliner")
|
|
21
|
+
|
|
22
|
+
# Representative length for warmup: GPU kernels are chosen per input shape.
|
|
23
|
+
_WARMUP_TEXT = (
|
|
24
|
+
"Hello, I placed order 4471 last Tuesday and the tracking page has not changed since. "
|
|
25
|
+
"Could you tell me when it will arrive, or refund it if it is lost? Thanks for your help."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _field_spec(name: str, description: str) -> str:
|
|
30
|
+
return f"{name}::str::{description.replace('::', ':')}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GLiNER(DecisionProvider):
|
|
34
|
+
"""Answers choice and extract questions with a GLiNER2 model.
|
|
35
|
+
|
|
36
|
+
Choice questions are scored per label (the model emits an independent
|
|
37
|
+
score for every option) and the scores are normalized into a distribution,
|
|
38
|
+
so confidence is comparable with other providers. All choice questions of
|
|
39
|
+
a call share one forward pass, and so do all extract questions.
|
|
40
|
+
|
|
41
|
+
Measured on an RTX 5060 laptop GPU with ``gliner2.5-base-v1``: about 16 ms
|
|
42
|
+
per classification and 38 ms per extraction; about 85 ms each on CPU.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
model: ``fastino/gliner2.5-base-v1`` (English, 0.2B),
|
|
46
|
+
``fastino/gliner2.5-small-v1`` (74M, fastest on CPU) or
|
|
47
|
+
``fastino/gliner2.5-multi-v1`` (multilingual).
|
|
48
|
+
device: ``auto``, ``cpu``, ``cuda`` or ``mps``.
|
|
49
|
+
name: Provider name in traces.
|
|
50
|
+
extract_threshold: Minimum span confidence for an extracted field.
|
|
51
|
+
|
|
52
|
+
Requires the ``gliner`` extra. Weights download on first use.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
plane: ClassVar[Plane] = Plane.MODEL
|
|
56
|
+
kinds: ClassVar[frozenset[Kind]] = frozenset({Kind.CHOICE, Kind.EXTRACT})
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
model: str = "fastino/gliner2.5-base-v1",
|
|
61
|
+
*,
|
|
62
|
+
device: str = "auto",
|
|
63
|
+
name: str = "gliner",
|
|
64
|
+
extract_threshold: float = 0.5,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.name = name
|
|
67
|
+
self.model = model
|
|
68
|
+
self._device_request = device
|
|
69
|
+
self._extract_threshold = extract_threshold
|
|
70
|
+
self._model: Any = None
|
|
71
|
+
self._lock = threading.Lock()
|
|
72
|
+
self.device: str | None = None
|
|
73
|
+
|
|
74
|
+
def warmup(self) -> None:
|
|
75
|
+
"""Load the weights and run representative inferences of each kind.
|
|
76
|
+
|
|
77
|
+
The first forward passes on a GPU are several times slower than the
|
|
78
|
+
rest while kernels initialize, and GLiNER's TorchScript parts only
|
|
79
|
+
specialize after a couple of runs; paying for that here keeps it off
|
|
80
|
+
the first requests.
|
|
81
|
+
"""
|
|
82
|
+
model = self._load()
|
|
83
|
+
labels = {f"option {i}": f"a description of option number {i}" for i in range(8)}
|
|
84
|
+
for _ in range(2):
|
|
85
|
+
self._warm(model, labels)
|
|
86
|
+
|
|
87
|
+
def _warm(self, model: Any, labels: dict[str, str]) -> None:
|
|
88
|
+
with self._lock:
|
|
89
|
+
model.classify_text(
|
|
90
|
+
_WARMUP_TEXT, {"w": {"labels": labels, "multi_label": True, "cls_threshold": 0.0}}
|
|
91
|
+
)
|
|
92
|
+
model.extract_json(
|
|
93
|
+
_WARMUP_TEXT, {"w": ["id::str::the reference number", "day::str::the day"]}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _load(self) -> Any:
|
|
97
|
+
if self._model is not None:
|
|
98
|
+
return self._model
|
|
99
|
+
with self._lock:
|
|
100
|
+
if self._model is None:
|
|
101
|
+
try:
|
|
102
|
+
from gliner2 import AutoExtractor
|
|
103
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
104
|
+
raise ImportError(
|
|
105
|
+
'The GLiNER provider needs the gliner extra: pip install "thinkless[gliner]"'
|
|
106
|
+
) from exc
|
|
107
|
+
device = resolve_device(self._device_request)
|
|
108
|
+
started = time.perf_counter()
|
|
109
|
+
ensure_downloaded(self.model)
|
|
110
|
+
model = AutoExtractor.from_pretrained(self.model)
|
|
111
|
+
if device != "cpu":
|
|
112
|
+
model = model.to(device)
|
|
113
|
+
self._model = model
|
|
114
|
+
self.device = device
|
|
115
|
+
logger.info(
|
|
116
|
+
"loaded %s on %s in %.1f s", self.model, device, time.perf_counter() - started
|
|
117
|
+
)
|
|
118
|
+
return self._model
|
|
119
|
+
|
|
120
|
+
def answer(self, state: State, questions: Mapping[str, Question]) -> ProviderResult:
|
|
121
|
+
model = self._load()
|
|
122
|
+
text = render_state(state)
|
|
123
|
+
answers: dict[str, Answer | None] = {}
|
|
124
|
+
|
|
125
|
+
choices = {k: q for k, q in questions.items() if isinstance(q, Choice)}
|
|
126
|
+
extracts = {k: q for k, q in questions.items() if isinstance(q, Extract)}
|
|
127
|
+
|
|
128
|
+
if choices:
|
|
129
|
+
tasks: dict[str, Any] = {}
|
|
130
|
+
for key, question in choices.items():
|
|
131
|
+
has_descriptions = all(question.options.values())
|
|
132
|
+
labels: Any = dict(question.options) if has_descriptions else question.labels
|
|
133
|
+
tasks[key] = {"labels": labels, "multi_label": True, "cls_threshold": 0.0}
|
|
134
|
+
with self._lock:
|
|
135
|
+
result = model.classify_text(text, tasks, include_confidence=True)
|
|
136
|
+
for key, question in choices.items():
|
|
137
|
+
answers[key] = self._choice_answer(result.get(key), question)
|
|
138
|
+
|
|
139
|
+
if extracts:
|
|
140
|
+
schema = {
|
|
141
|
+
key: [_field_spec(name, desc) for name, desc in q.fields.items()]
|
|
142
|
+
for key, q in extracts.items()
|
|
143
|
+
}
|
|
144
|
+
with self._lock:
|
|
145
|
+
result = model.extract_json(
|
|
146
|
+
text, schema, threshold=self._extract_threshold, include_confidence=True
|
|
147
|
+
)
|
|
148
|
+
for key, extract in extracts.items():
|
|
149
|
+
answers[key] = self._extract_answer(result.get(key), extract)
|
|
150
|
+
|
|
151
|
+
return ProviderResult(answers=answers, model=self.model, meta={"device": self.device})
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _choice_answer(raw: Any, question: Choice) -> Answer | None:
|
|
155
|
+
if not raw:
|
|
156
|
+
return None
|
|
157
|
+
items = raw if isinstance(raw, list) else [raw]
|
|
158
|
+
scores = dict.fromkeys(question.labels, 0.0)
|
|
159
|
+
for item in items:
|
|
160
|
+
label = item.get("label") if isinstance(item, Mapping) else None
|
|
161
|
+
if label in scores:
|
|
162
|
+
scores[label] = float(item.get("confidence") or 0.0)
|
|
163
|
+
if not any(scores.values()):
|
|
164
|
+
return None
|
|
165
|
+
probabilities = normalize_distribution(scores)
|
|
166
|
+
best = max(probabilities, key=probabilities.__getitem__)
|
|
167
|
+
return Answer(value=best, probabilities=probabilities, raw={"scores": scores})
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _extract_answer(raw: Any, question: Extract) -> Answer:
|
|
171
|
+
record: Mapping[str, Any] = {}
|
|
172
|
+
if isinstance(raw, list) and raw:
|
|
173
|
+
record = raw[0] if isinstance(raw[0], Mapping) else {}
|
|
174
|
+
elif isinstance(raw, Mapping):
|
|
175
|
+
record = raw
|
|
176
|
+
values: dict[str, Any] = {}
|
|
177
|
+
confidences: dict[str, float | None] = {}
|
|
178
|
+
for name in question.fields:
|
|
179
|
+
item = record.get(name)
|
|
180
|
+
if isinstance(item, Mapping):
|
|
181
|
+
values[name] = item.get("text")
|
|
182
|
+
confidences[name] = (
|
|
183
|
+
float(item["confidence"]) if item.get("confidence") is not None else None
|
|
184
|
+
)
|
|
185
|
+
elif isinstance(item, list) and item and isinstance(item[0], Mapping):
|
|
186
|
+
values[name] = item[0].get("text")
|
|
187
|
+
confidences[name] = item[0].get("confidence")
|
|
188
|
+
else:
|
|
189
|
+
values[name] = item if isinstance(item, str) else None
|
|
190
|
+
confidences[name] = None
|
|
191
|
+
return Answer(value=values, fields=confidences, raw={"record": dict(record)})
|
|
192
|
+
|
|
193
|
+
def close(self) -> None:
|
|
194
|
+
self._model = None
|