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,126 @@
|
|
|
1
|
+
"""Claude through the official Anthropic SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..decision import Usage
|
|
10
|
+
from .base import LLM, Completion, Message
|
|
11
|
+
|
|
12
|
+
__all__ = ["AnthropicLLM"]
|
|
13
|
+
|
|
14
|
+
FALLBACK_BETA = "server-side-fallback-2026-07-01"
|
|
15
|
+
|
|
16
|
+
# Model families for which server-side refusal fallbacks are documented.
|
|
17
|
+
FALLBACK_FAMILIES = ("claude-opus-5", "claude-fable-5", "claude-mythos-5")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AnthropicLLM(LLM):
|
|
21
|
+
"""Claude models via the Messages API.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
model: Claude model id. Defaults to ``claude-opus-5``. For
|
|
25
|
+
high-volume reply generation, ``claude-sonnet-5`` or
|
|
26
|
+
``claude-haiku-4-5`` are the cheaper options.
|
|
27
|
+
api_key: Defaults to the SDK's credential resolution
|
|
28
|
+
(``ANTHROPIC_API_KEY``, ``ANTHROPIC_AUTH_TOKEN`` or an
|
|
29
|
+
``ant auth login`` profile).
|
|
30
|
+
effort: Optional ``output_config.effort`` (``low`` to ``max``).
|
|
31
|
+
``low`` suits short replies and decision questions. Supported on
|
|
32
|
+
Claude Opus 4.6 and later, Sonnet 5 and Fable; Haiku 4.5 and
|
|
33
|
+
Sonnet 4.5 reject it, so leave it unset for those.
|
|
34
|
+
fallbacks: Server-side refusal fallbacks (``fallbacks="default"``):
|
|
35
|
+
if a safety classifier declines the request, the API re-runs it
|
|
36
|
+
on the recommended fallback model in the same call. ``None`` (the
|
|
37
|
+
default) turns them on for the model families that document them
|
|
38
|
+
(Opus 5 and later, Fable 5, Mythos 5) and off otherwise. Available
|
|
39
|
+
on the Claude API and Claude Platform on AWS; pass ``False`` when
|
|
40
|
+
routing through Bedrock, Vertex AI or Foundry.
|
|
41
|
+
client: A preconfigured ``anthropic.Anthropic`` client (tests, proxies,
|
|
42
|
+
custom base URL).
|
|
43
|
+
|
|
44
|
+
Sampling parameters are not sent: current Claude models reject
|
|
45
|
+
``temperature`` and control depth through ``effort`` instead.
|
|
46
|
+
|
|
47
|
+
Requires the ``anthropic`` extra.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
provider = "anthropic"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
model: str = "claude-opus-5",
|
|
55
|
+
*,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
effort: str | None = None,
|
|
58
|
+
fallbacks: bool | None = None,
|
|
59
|
+
timeout: float = 120.0,
|
|
60
|
+
max_retries: int = 2,
|
|
61
|
+
client: Any | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.model = model
|
|
64
|
+
self._effort = effort
|
|
65
|
+
self._fallbacks = model.startswith(FALLBACK_FAMILIES) if fallbacks is None else fallbacks
|
|
66
|
+
if client is not None:
|
|
67
|
+
self._client = client
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
import anthropic
|
|
71
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
72
|
+
raise ImportError(
|
|
73
|
+
'AnthropicLLM needs the anthropic extra: pip install "thinkless[anthropic]"'
|
|
74
|
+
) from exc
|
|
75
|
+
kwargs: dict[str, Any] = {"timeout": timeout, "max_retries": max_retries}
|
|
76
|
+
if api_key:
|
|
77
|
+
kwargs["api_key"] = api_key
|
|
78
|
+
self._client = anthropic.Anthropic(**kwargs)
|
|
79
|
+
|
|
80
|
+
def complete(
|
|
81
|
+
self,
|
|
82
|
+
messages: Sequence[Message],
|
|
83
|
+
*,
|
|
84
|
+
system: str | None = None,
|
|
85
|
+
max_tokens: int = 1024,
|
|
86
|
+
temperature: float | None = None,
|
|
87
|
+
json_mode: bool = False,
|
|
88
|
+
) -> Completion:
|
|
89
|
+
kwargs: dict[str, Any] = {
|
|
90
|
+
"model": self.model,
|
|
91
|
+
"max_tokens": max_tokens,
|
|
92
|
+
"messages": [dict(m) for m in messages],
|
|
93
|
+
}
|
|
94
|
+
if system:
|
|
95
|
+
kwargs["system"] = system
|
|
96
|
+
if self._effort:
|
|
97
|
+
kwargs["output_config"] = {"effort": self._effort}
|
|
98
|
+
started = time.perf_counter()
|
|
99
|
+
if self._fallbacks:
|
|
100
|
+
response = self._client.beta.messages.create(
|
|
101
|
+
betas=[FALLBACK_BETA], fallbacks="default", **kwargs
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
response = self._client.messages.create(**kwargs)
|
|
105
|
+
latency = (time.perf_counter() - started) * 1000.0
|
|
106
|
+
|
|
107
|
+
# A refusal arrives as HTTP 200 with stop_reason "refusal"; content may
|
|
108
|
+
# be empty or partial, so it is never treated as an answer.
|
|
109
|
+
if response.stop_reason == "refusal":
|
|
110
|
+
text = ""
|
|
111
|
+
else:
|
|
112
|
+
text = "".join(
|
|
113
|
+
block.text for block in response.content if getattr(block, "type", None) == "text"
|
|
114
|
+
)
|
|
115
|
+
usage = response.usage
|
|
116
|
+
return Completion(
|
|
117
|
+
text=text,
|
|
118
|
+
model=getattr(response, "model", None) or self.model,
|
|
119
|
+
usage=Usage(
|
|
120
|
+
input_tokens=getattr(usage, "input_tokens", 0) or 0,
|
|
121
|
+
output_tokens=getattr(usage, "output_tokens", 0) or 0,
|
|
122
|
+
),
|
|
123
|
+
latency_ms=latency,
|
|
124
|
+
stop_reason=response.stop_reason,
|
|
125
|
+
raw={"request_id": getattr(response, "_request_id", None)},
|
|
126
|
+
)
|
thinkless/llm/base.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The reasoning plane interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from ..decision import Usage
|
|
12
|
+
|
|
13
|
+
__all__ = ["LLM", "Completion", "Message", "as_messages"]
|
|
14
|
+
|
|
15
|
+
Message = dict[str, str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def as_messages(prompt: str | Sequence[Message]) -> list[Message]:
|
|
19
|
+
"""Accept a plain prompt or a list of ``{"role", "content"}`` messages."""
|
|
20
|
+
if isinstance(prompt, str):
|
|
21
|
+
return [{"role": "user", "content": prompt}]
|
|
22
|
+
return [dict(m) for m in prompt]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Completion(BaseModel):
|
|
26
|
+
"""The result of one generation.
|
|
27
|
+
|
|
28
|
+
``cost_usd`` is the cost the backend reported for this call (OpenRouter
|
|
29
|
+
does). When it is ``None`` the engine estimates cost from the price table.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
text: str
|
|
35
|
+
model: str
|
|
36
|
+
usage: Usage = Field(default_factory=Usage)
|
|
37
|
+
latency_ms: float = 0.0
|
|
38
|
+
stop_reason: str | None = None
|
|
39
|
+
cost_usd: float | None = None
|
|
40
|
+
raw: dict[str, Any] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LLM(ABC):
|
|
44
|
+
"""A generative model.
|
|
45
|
+
|
|
46
|
+
Implementations wrap one backend. ``provider`` identifies the backend in
|
|
47
|
+
traces and in the price table (``anthropic``, ``openai``, ``local``, ...).
|
|
48
|
+
|
|
49
|
+
Args of :meth:`complete`:
|
|
50
|
+
messages: Conversation turns, oldest first.
|
|
51
|
+
system: Optional system prompt.
|
|
52
|
+
max_tokens: Upper bound on generated tokens.
|
|
53
|
+
temperature: Sampling temperature. ``None`` leaves the backend
|
|
54
|
+
default; some hosted models reject the parameter entirely, and
|
|
55
|
+
their implementations ignore it.
|
|
56
|
+
json_mode: Ask the backend for a JSON object when it supports that.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
provider: str = "llm"
|
|
60
|
+
model: str = "unknown"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def name(self) -> str:
|
|
64
|
+
return f"{self.provider}:{self.model}"
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def complete(
|
|
68
|
+
self,
|
|
69
|
+
messages: Sequence[Message],
|
|
70
|
+
*,
|
|
71
|
+
system: str | None = None,
|
|
72
|
+
max_tokens: int = 512,
|
|
73
|
+
temperature: float | None = None,
|
|
74
|
+
json_mode: bool = False,
|
|
75
|
+
) -> Completion:
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def warmup(self) -> None: # noqa: B027 - optional hook
|
|
79
|
+
"""Load weights or open connections ahead of the first call."""
|
|
80
|
+
|
|
81
|
+
def close(self) -> None: # noqa: B027 - optional hook
|
|
82
|
+
"""Release resources."""
|
thinkless/llm/factory.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Build an LLM from a short spec string, as used by the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import LLM
|
|
6
|
+
|
|
7
|
+
__all__ = ["DEFAULT_LOCAL_MODEL", "from_spec"]
|
|
8
|
+
|
|
9
|
+
DEFAULT_LOCAL_MODEL = "Qwen/Qwen3-1.7B"
|
|
10
|
+
|
|
11
|
+
BACKENDS = ("local", "anthropic", "openrouter", "openai", "ollama", "vllm")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
REASONING = ("default", "off", "minimal", "low", "medium", "high")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def from_spec(
|
|
18
|
+
spec: str,
|
|
19
|
+
*,
|
|
20
|
+
device: str = "auto",
|
|
21
|
+
base_url: str | None = None,
|
|
22
|
+
reasoning: str | None = None,
|
|
23
|
+
) -> LLM:
|
|
24
|
+
"""Create an LLM from ``backend[:model]``.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
``local`` (Qwen3-1.7B in-process), ``local:Qwen/Qwen3-4B``,
|
|
28
|
+
``anthropic`` (Claude Opus 5), ``anthropic:claude-haiku-4-5``,
|
|
29
|
+
``openrouter:qwen/qwen3.7-flash``, ``openrouter:anthropic/claude-haiku-4.5``,
|
|
30
|
+
``openai:<model>``, ``ollama:qwen3:8b``, ``vllm:<model>``.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
spec: Backend name, optionally followed by a colon and a model id.
|
|
34
|
+
device: Device for the ``local`` backend.
|
|
35
|
+
base_url: Server URL for ``openai``, ``ollama`` and ``vllm``.
|
|
36
|
+
reasoning: ``off``, ``minimal``, ``low``, ``medium`` or ``high``;
|
|
37
|
+
``None`` or ``default`` keeps the model's own default. Mapped to
|
|
38
|
+
OpenRouter's ``reasoning`` option, Anthropic's ``effort``, and the
|
|
39
|
+
thinking switch of local chat templates. Short decisions rarely
|
|
40
|
+
need reasoning, and hidden reasoning tokens are billed and count
|
|
41
|
+
against the output limit.
|
|
42
|
+
"""
|
|
43
|
+
if reasoning not in (None, *REASONING):
|
|
44
|
+
raise ValueError(f"reasoning must be one of {', '.join(REASONING)}")
|
|
45
|
+
level = None if reasoning in (None, "default") else reasoning
|
|
46
|
+
backend, _, model = spec.partition(":")
|
|
47
|
+
backend = backend.strip().lower()
|
|
48
|
+
model = model.strip()
|
|
49
|
+
if backend == "local":
|
|
50
|
+
from .local import TransformersLLM
|
|
51
|
+
|
|
52
|
+
return TransformersLLM(
|
|
53
|
+
model or DEFAULT_LOCAL_MODEL, device=device, enable_thinking=level not in (None, "off")
|
|
54
|
+
)
|
|
55
|
+
if backend == "anthropic":
|
|
56
|
+
from .anthropic import AnthropicLLM
|
|
57
|
+
|
|
58
|
+
effort = {"minimal": "low", "off": None}.get(level or "", level)
|
|
59
|
+
return AnthropicLLM(model or "claude-opus-5", effort=effort)
|
|
60
|
+
if backend == "openrouter":
|
|
61
|
+
from .openrouter import OpenRouterLLM
|
|
62
|
+
|
|
63
|
+
if not model:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"the openrouter backend needs a model slug, for example "
|
|
66
|
+
"openrouter:qwen/qwen3.7-flash"
|
|
67
|
+
)
|
|
68
|
+
options = (
|
|
69
|
+
None if level is None else {"enabled": False} if level == "off" else {"effort": level}
|
|
70
|
+
)
|
|
71
|
+
return OpenRouterLLM(model, reasoning=options)
|
|
72
|
+
if backend in ("openai", "ollama", "vllm"):
|
|
73
|
+
from .openai_compat import OpenAICompatibleLLM
|
|
74
|
+
|
|
75
|
+
if backend == "openai":
|
|
76
|
+
if not model:
|
|
77
|
+
raise ValueError("the openai backend needs a model, for example openai:<model-id>")
|
|
78
|
+
return OpenAICompatibleLLM(model, base_url=base_url)
|
|
79
|
+
defaults = {
|
|
80
|
+
"ollama": ("qwen3:4b", "http://localhost:11434/v1"),
|
|
81
|
+
"vllm": ("", "http://localhost:8000/v1"),
|
|
82
|
+
}
|
|
83
|
+
default_model, default_url = defaults[backend]
|
|
84
|
+
if not (model or default_model):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"the {backend} backend needs a model, for example {backend}:<model-id>"
|
|
87
|
+
)
|
|
88
|
+
return OpenAICompatibleLLM(
|
|
89
|
+
model or default_model, base_url=base_url or default_url, provider=backend
|
|
90
|
+
)
|
|
91
|
+
raise ValueError(f"unknown LLM backend {backend!r}; choose one of {', '.join(BACKENDS)}")
|
thinkless/llm/local.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Local generation with Hugging Face Transformers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .._hub import ensure_downloaded
|
|
13
|
+
from ..decision import Usage
|
|
14
|
+
from ..logs import get_logger
|
|
15
|
+
from ..settings import resolve_device
|
|
16
|
+
from .base import LLM, Completion, Message
|
|
17
|
+
|
|
18
|
+
__all__ = ["TransformersLLM"]
|
|
19
|
+
|
|
20
|
+
logger = get_logger("llm.local")
|
|
21
|
+
|
|
22
|
+
_THINK = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransformersLLM(LLM):
|
|
26
|
+
"""Runs a chat model in-process with ``transformers``.
|
|
27
|
+
|
|
28
|
+
This is the zero-setup option: no server, no API key. For throughput, run
|
|
29
|
+
the same weights behind Ollama, vLLM or llama.cpp and use
|
|
30
|
+
:class:`~thinkless.llm.OpenAICompatibleLLM` instead; a serving engine is
|
|
31
|
+
several times faster than the plain ``generate`` loop used here.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
model: Hugging Face model id or local path.
|
|
35
|
+
device: ``auto``, ``cpu``, ``cuda``, ``cuda:N`` or ``mps``.
|
|
36
|
+
dtype: ``auto`` picks bfloat16 on CUDA, float16 on MPS, float32 on CPU.
|
|
37
|
+
enable_thinking: Passed to chat templates that support a thinking
|
|
38
|
+
switch (Qwen3 and later). Off by default: decisions and short
|
|
39
|
+
replies do not benefit from long reasoning traces.
|
|
40
|
+
|
|
41
|
+
Requires the ``local-llm`` extra.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
provider = "local"
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
model: str = "Qwen/Qwen3-1.7B",
|
|
49
|
+
*,
|
|
50
|
+
device: str = "auto",
|
|
51
|
+
dtype: str = "auto",
|
|
52
|
+
enable_thinking: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.model = model
|
|
55
|
+
self._device_request = device
|
|
56
|
+
self._dtype_request = dtype
|
|
57
|
+
self._enable_thinking = enable_thinking
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._tokenizer: Any = None
|
|
60
|
+
self._model: Any = None
|
|
61
|
+
self.device: str | None = None
|
|
62
|
+
|
|
63
|
+
def warmup(self) -> None:
|
|
64
|
+
"""Load the weights and generate one token, so the first request is not slow."""
|
|
65
|
+
self._load()
|
|
66
|
+
self.complete(
|
|
67
|
+
[{"role": "user", "content": "Reply with a short greeting for a customer."}],
|
|
68
|
+
max_tokens=16,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _load(self) -> None:
|
|
72
|
+
if self._model is not None:
|
|
73
|
+
return
|
|
74
|
+
with self._lock:
|
|
75
|
+
if self._model is not None:
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
import torch
|
|
79
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
80
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
81
|
+
raise ImportError(
|
|
82
|
+
'TransformersLLM needs the local-llm extra: pip install "thinkless[local-llm]"'
|
|
83
|
+
) from exc
|
|
84
|
+
device = resolve_device(self._device_request)
|
|
85
|
+
dtype: Any
|
|
86
|
+
if self._dtype_request != "auto":
|
|
87
|
+
dtype = getattr(torch, self._dtype_request)
|
|
88
|
+
elif device.startswith("cuda"):
|
|
89
|
+
dtype = torch.bfloat16
|
|
90
|
+
elif device == "mps":
|
|
91
|
+
dtype = torch.float16
|
|
92
|
+
else:
|
|
93
|
+
dtype = torch.float32
|
|
94
|
+
started = time.perf_counter()
|
|
95
|
+
ensure_downloaded(self.model)
|
|
96
|
+
tokenizer = AutoTokenizer.from_pretrained(self.model)
|
|
97
|
+
model: Any = AutoModelForCausalLM.from_pretrained(self.model, dtype=dtype)
|
|
98
|
+
model.to(device)
|
|
99
|
+
model.eval()
|
|
100
|
+
config: Any = copy.deepcopy(model.generation_config)
|
|
101
|
+
# Greedy decoding by default; clear sampling fields so transformers
|
|
102
|
+
# does not warn about unused flags on every call.
|
|
103
|
+
config.do_sample = False
|
|
104
|
+
config.temperature = None
|
|
105
|
+
config.top_p = None
|
|
106
|
+
config.top_k = None
|
|
107
|
+
if config.pad_token_id is None:
|
|
108
|
+
config.pad_token_id = tokenizer.pad_token_id or tokenizer.eos_token_id
|
|
109
|
+
self._generation_config = config
|
|
110
|
+
self._tokenizer = tokenizer
|
|
111
|
+
self._model = model
|
|
112
|
+
self.device = device
|
|
113
|
+
logger.info(
|
|
114
|
+
"loaded %s on %s in %.1f s", self.model, device, time.perf_counter() - started
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def complete(
|
|
118
|
+
self,
|
|
119
|
+
messages: Sequence[Message],
|
|
120
|
+
*,
|
|
121
|
+
system: str | None = None,
|
|
122
|
+
max_tokens: int = 512,
|
|
123
|
+
temperature: float | None = None,
|
|
124
|
+
json_mode: bool = False,
|
|
125
|
+
) -> Completion:
|
|
126
|
+
self._load()
|
|
127
|
+
import torch
|
|
128
|
+
|
|
129
|
+
chat: list[Message] = []
|
|
130
|
+
if system:
|
|
131
|
+
chat.append({"role": "system", "content": system})
|
|
132
|
+
chat.extend(messages)
|
|
133
|
+
started = time.perf_counter()
|
|
134
|
+
with self._lock:
|
|
135
|
+
inputs = self._tokenizer.apply_chat_template(
|
|
136
|
+
chat,
|
|
137
|
+
add_generation_prompt=True,
|
|
138
|
+
return_tensors="pt",
|
|
139
|
+
return_dict=True,
|
|
140
|
+
enable_thinking=self._enable_thinking,
|
|
141
|
+
).to(self.device)
|
|
142
|
+
config: Any = copy.deepcopy(self._generation_config)
|
|
143
|
+
config.max_new_tokens = max_tokens
|
|
144
|
+
if temperature:
|
|
145
|
+
config.do_sample = True
|
|
146
|
+
config.temperature = temperature
|
|
147
|
+
with torch.inference_mode():
|
|
148
|
+
# use_model_defaults=False stops transformers from swapping our
|
|
149
|
+
# greedy settings for the checkpoint's sampling defaults.
|
|
150
|
+
output = self._model.generate(
|
|
151
|
+
**inputs, generation_config=config, use_model_defaults=False
|
|
152
|
+
)
|
|
153
|
+
prompt_len = int(inputs["input_ids"].shape[1])
|
|
154
|
+
new_tokens = output[0][prompt_len:]
|
|
155
|
+
text = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
|
|
156
|
+
text = _THINK.sub("", text).strip()
|
|
157
|
+
n_out = int(new_tokens.shape[0])
|
|
158
|
+
return Completion(
|
|
159
|
+
text=text,
|
|
160
|
+
model=self.model,
|
|
161
|
+
usage=Usage(input_tokens=prompt_len, output_tokens=n_out),
|
|
162
|
+
latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
163
|
+
stop_reason="max_tokens" if n_out >= max_tokens else "end_turn",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
self._model = None
|
|
168
|
+
self._tokenizer = None
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Any server that speaks the OpenAI Chat Completions API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..decision import Usage
|
|
11
|
+
from ..logs import get_logger
|
|
12
|
+
from .base import LLM, Completion, Message
|
|
13
|
+
|
|
14
|
+
__all__ = ["OpenAICompatibleLLM", "reported_cost"]
|
|
15
|
+
|
|
16
|
+
logger = get_logger("llm.openai")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reported_cost(usage: Any) -> float | None:
|
|
20
|
+
"""The dollar cost a server reports in ``usage``, if it reports one.
|
|
21
|
+
|
|
22
|
+
OpenRouter adds ``usage.cost`` to every response. The OpenAI SDK keeps
|
|
23
|
+
fields it does not know in ``model_extra``, so both places are checked.
|
|
24
|
+
"""
|
|
25
|
+
if usage is None:
|
|
26
|
+
return None
|
|
27
|
+
value = getattr(usage, "cost", None)
|
|
28
|
+
if value is None:
|
|
29
|
+
extra = getattr(usage, "model_extra", None) or {}
|
|
30
|
+
value = extra.get("cost")
|
|
31
|
+
try:
|
|
32
|
+
return None if value is None else float(value)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OpenAICompatibleLLM(LLM):
|
|
38
|
+
"""Chat Completions client for OpenAI and compatible servers.
|
|
39
|
+
|
|
40
|
+
Covers OpenAI itself plus Ollama, vLLM, LM Studio, llama.cpp server,
|
|
41
|
+
Groq, Together and any other server that implements
|
|
42
|
+
``/v1/chat/completions``. For OpenRouter, use
|
|
43
|
+
:class:`~thinkless.llm.OpenRouterLLM`, which sets the endpoint, key and
|
|
44
|
+
reported cost for you.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
model: Model id as the server knows it.
|
|
48
|
+
base_url: Server URL, for example ``http://localhost:11434/v1`` for
|
|
49
|
+
Ollama. ``None`` targets api.openai.com.
|
|
50
|
+
api_key: Defaults to ``OPENAI_API_KEY``. Local servers usually accept
|
|
51
|
+
any value.
|
|
52
|
+
provider: Name used in traces and for price lookup. Use ``ollama``,
|
|
53
|
+
``vllm`` or ``lmstudio`` for local servers so their cost is 0.
|
|
54
|
+
token_param: ``max_completion_tokens`` (OpenAI's current name) or
|
|
55
|
+
``max_tokens`` (what most compatible servers accept). Chosen from
|
|
56
|
+
``base_url`` when omitted.
|
|
57
|
+
supports_json_mode: Whether to send ``response_format`` when a caller
|
|
58
|
+
asks for JSON. If the server rejects it, the client retries once
|
|
59
|
+
without it and stops sending it.
|
|
60
|
+
default_headers: Extra HTTP headers sent with every request.
|
|
61
|
+
extra_body: Extra fields merged into every request body, for
|
|
62
|
+
server-specific options.
|
|
63
|
+
|
|
64
|
+
Requires the ``openai`` extra.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
model: str,
|
|
70
|
+
*,
|
|
71
|
+
base_url: str | None = None,
|
|
72
|
+
api_key: str | None = None,
|
|
73
|
+
provider: str = "openai",
|
|
74
|
+
token_param: str | None = None,
|
|
75
|
+
supports_json_mode: bool = True,
|
|
76
|
+
default_headers: Mapping[str, str] | None = None,
|
|
77
|
+
extra_body: Mapping[str, Any] | None = None,
|
|
78
|
+
timeout: float = 120.0,
|
|
79
|
+
max_retries: int = 2,
|
|
80
|
+
client: Any | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.model = model
|
|
83
|
+
self.provider = provider
|
|
84
|
+
self._token_param = token_param or ("max_tokens" if base_url else "max_completion_tokens")
|
|
85
|
+
self._json_mode = supports_json_mode
|
|
86
|
+
self._extra_body = dict(extra_body or {})
|
|
87
|
+
if client is not None:
|
|
88
|
+
self._client = client
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
from openai import OpenAI
|
|
92
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
93
|
+
raise ImportError(
|
|
94
|
+
'OpenAICompatibleLLM needs the openai extra: pip install "thinkless[openai]"'
|
|
95
|
+
) from exc
|
|
96
|
+
key = api_key or os.environ.get("OPENAI_API_KEY") or ("unused" if base_url else None)
|
|
97
|
+
self._client = OpenAI(
|
|
98
|
+
base_url=base_url,
|
|
99
|
+
api_key=key,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
max_retries=max_retries,
|
|
102
|
+
default_headers=dict(default_headers) if default_headers else None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def complete(
|
|
106
|
+
self,
|
|
107
|
+
messages: Sequence[Message],
|
|
108
|
+
*,
|
|
109
|
+
system: str | None = None,
|
|
110
|
+
max_tokens: int = 512,
|
|
111
|
+
temperature: float | None = None,
|
|
112
|
+
json_mode: bool = False,
|
|
113
|
+
) -> Completion:
|
|
114
|
+
chat: list[dict[str, Any]] = []
|
|
115
|
+
if system:
|
|
116
|
+
chat.append({"role": "system", "content": system})
|
|
117
|
+
chat.extend(dict(m) for m in messages)
|
|
118
|
+
kwargs: dict[str, Any] = {
|
|
119
|
+
"model": self.model,
|
|
120
|
+
"messages": chat,
|
|
121
|
+
self._token_param: max_tokens,
|
|
122
|
+
}
|
|
123
|
+
if temperature is not None:
|
|
124
|
+
kwargs["temperature"] = temperature
|
|
125
|
+
if self._extra_body:
|
|
126
|
+
kwargs["extra_body"] = dict(self._extra_body)
|
|
127
|
+
use_json = json_mode and self._json_mode
|
|
128
|
+
if use_json:
|
|
129
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
130
|
+
started = time.perf_counter()
|
|
131
|
+
try:
|
|
132
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
if not (use_json and _rejects_response_format(exc)):
|
|
135
|
+
raise
|
|
136
|
+
logger.warning(
|
|
137
|
+
"%s rejected response_format; retrying without JSON mode for this client",
|
|
138
|
+
self.model,
|
|
139
|
+
)
|
|
140
|
+
self._json_mode = False
|
|
141
|
+
kwargs.pop("response_format", None)
|
|
142
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
143
|
+
latency = (time.perf_counter() - started) * 1000.0
|
|
144
|
+
choice = response.choices[0]
|
|
145
|
+
usage = response.usage
|
|
146
|
+
return Completion(
|
|
147
|
+
text=choice.message.content or "",
|
|
148
|
+
model=getattr(response, "model", None) or self.model,
|
|
149
|
+
usage=Usage(
|
|
150
|
+
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
|
|
151
|
+
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
|
|
152
|
+
),
|
|
153
|
+
latency_ms=latency,
|
|
154
|
+
stop_reason=choice.finish_reason,
|
|
155
|
+
cost_usd=reported_cost(usage),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _rejects_response_format(exc: Exception) -> bool:
|
|
160
|
+
"""True for a client error that names response_format (or JSON mode)."""
|
|
161
|
+
status = getattr(exc, "status_code", None)
|
|
162
|
+
if status is not None and not 400 <= int(status) < 500:
|
|
163
|
+
return False
|
|
164
|
+
text = str(exc).lower()
|
|
165
|
+
return "response_format" in text or "json_object" in text or "json mode" in text
|