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,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."""
@@ -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