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.
Files changed (63) hide show
  1. thinkless/__init__.py +63 -0
  2. thinkless/__main__.py +3 -0
  3. thinkless/_hub.py +84 -0
  4. thinkless/_json.py +33 -0
  5. thinkless/_version.py +1 -0
  6. thinkless/bench/__init__.py +22 -0
  7. thinkless/bench/intents.py +406 -0
  8. thinkless/bench/intents_report.py +87 -0
  9. thinkless/bench/metrics.py +115 -0
  10. thinkless/bench/report.py +123 -0
  11. thinkless/bench/support.py +342 -0
  12. thinkless/cli/__init__.py +1 -0
  13. thinkless/cli/main.py +595 -0
  14. thinkless/confidence.py +62 -0
  15. thinkless/data/pricing.toml +56 -0
  16. thinkless/data/viewer.html +411 -0
  17. thinkless/decision.py +185 -0
  18. thinkless/demo/__init__.py +1 -0
  19. thinkless/demo/support/__init__.py +19 -0
  20. thinkless/demo/support/agent.py +379 -0
  21. thinkless/demo/support/data/calibration.jsonl +48 -0
  22. thinkless/demo/support/data/scenarios.jsonl +53 -0
  23. thinkless/demo/support/data/world.json +78 -0
  24. thinkless/demo/support/questions.py +118 -0
  25. thinkless/demo/support/stack.py +93 -0
  26. thinkless/demo/support/world.py +165 -0
  27. thinkless/engine.py +605 -0
  28. thinkless/errors.py +13 -0
  29. thinkless/llm/__init__.py +47 -0
  30. thinkless/llm/anthropic.py +126 -0
  31. thinkless/llm/base.py +82 -0
  32. thinkless/llm/factory.py +91 -0
  33. thinkless/llm/local.py +168 -0
  34. thinkless/llm/openai_compat.py +165 -0
  35. thinkless/llm/openrouter.py +78 -0
  36. thinkless/llm/scripted.py +75 -0
  37. thinkless/logs.py +68 -0
  38. thinkless/pricing.py +100 -0
  39. thinkless/providers/__init__.py +43 -0
  40. thinkless/providers/base.py +108 -0
  41. thinkless/providers/gliner.py +194 -0
  42. thinkless/providers/hf.py +161 -0
  43. thinkless/providers/laya.py +121 -0
  44. thinkless/providers/llm.py +305 -0
  45. thinkless/providers/rules.py +181 -0
  46. thinkless/providers/systemone.py +147 -0
  47. thinkless/providers/wire.py +108 -0
  48. thinkless/py.typed +0 -0
  49. thinkless/questions.py +246 -0
  50. thinkless/settings.py +93 -0
  51. thinkless/tracing/__init__.py +23 -0
  52. thinkless/tracing/console.py +169 -0
  53. thinkless/tracing/otel.py +139 -0
  54. thinkless/tracing/sinks.py +142 -0
  55. thinkless/tracing/span.py +98 -0
  56. thinkless/tracing/summary.py +143 -0
  57. thinkless/tracing/tracer.py +159 -0
  58. thinkless/tracing/viewer.py +87 -0
  59. thinkless-0.2.0.dist-info/METADATA +357 -0
  60. thinkless-0.2.0.dist-info/RECORD +63 -0
  61. thinkless-0.2.0.dist-info/WHEEL +4 -0
  62. thinkless-0.2.0.dist-info/entry_points.txt +2 -0
  63. 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