llm-interface 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {llm_interface-0.2.0 → llm_interface-0.2.2}/PKG-INFO +1 -1
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/__init__.py +1 -1
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/anthropic.py +30 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/errors.py +1 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/llm_config.py +21 -9
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/llm_interface.py +38 -15
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/openai.py +130 -25
- llm_interface-0.2.2/llm_interface/openai_responses.py +351 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/token_usage.py +17 -2
- {llm_interface-0.2.0 → llm_interface-0.2.2}/pyproject.toml +1 -1
- {llm_interface-0.2.0 → llm_interface-0.2.2}/LICENSE +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/README.md +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/gemini.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/llm_tool.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/ollama.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/openrouter.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/pydantic_output_parser.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/remote_ollama.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/ssh.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/testing/__init__.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/testing/helpers.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/testing/mock_llm.py +0 -0
- {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/utils.py +0 -0
|
@@ -214,6 +214,32 @@ def convert_anthropic_models_to_ollama_response(
|
|
|
214
214
|
return ListResponse(models=ollama_models)
|
|
215
215
|
|
|
216
216
|
|
|
217
|
+
def _usage_int(value: Any) -> int:
|
|
218
|
+
"""Usage counters that the SDK may leave unset (or that tests mock) become 0."""
|
|
219
|
+
return value if isinstance(value, int) else 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _estimate_thinking_tokens(response: Any) -> int:
|
|
223
|
+
"""Thinking tokens are billed as output but the API does not count them
|
|
224
|
+
separately. When the response carries thinking blocks, estimate them as the
|
|
225
|
+
output tokens that the visible text and tool-call blocks do not account for."""
|
|
226
|
+
blocks = list(getattr(response, "content", None) or [])
|
|
227
|
+
if not any(getattr(block, "type", None) == "thinking" for block in blocks):
|
|
228
|
+
return 0
|
|
229
|
+
visible_chars = 0
|
|
230
|
+
for block in blocks:
|
|
231
|
+
block_type = getattr(block, "type", None)
|
|
232
|
+
if block_type == "text":
|
|
233
|
+
visible_chars += len(getattr(block, "text", "") or "")
|
|
234
|
+
elif block_type == "tool_use":
|
|
235
|
+
try:
|
|
236
|
+
visible_chars += len(json.dumps(getattr(block, "input", None)))
|
|
237
|
+
except (TypeError, ValueError):
|
|
238
|
+
pass
|
|
239
|
+
output_tokens = _usage_int(getattr(response.usage, "output_tokens", 0))
|
|
240
|
+
return max(0, output_tokens - visible_chars // 4)
|
|
241
|
+
|
|
242
|
+
|
|
217
243
|
class AnthropicWrapper:
|
|
218
244
|
def __init__(
|
|
219
245
|
self,
|
|
@@ -351,7 +377,11 @@ class AnthropicWrapper:
|
|
|
351
377
|
"prompt_tokens": usage.input_tokens,
|
|
352
378
|
"completion_tokens": usage.output_tokens,
|
|
353
379
|
"cached_tokens": usage.cache_read_input_tokens or 0,
|
|
380
|
+
"cache_creation_tokens": _usage_int(
|
|
381
|
+
getattr(usage, "cache_creation_input_tokens", 0)
|
|
382
|
+
),
|
|
354
383
|
"total_tokens": usage.input_tokens + usage.output_tokens,
|
|
384
|
+
"reasoning_tokens": _estimate_thinking_tokens(response),
|
|
355
385
|
}
|
|
356
386
|
|
|
357
387
|
if response.stop_reason == "refusal":
|
|
@@ -7,6 +7,7 @@ from .anthropic import AnthropicWrapper
|
|
|
7
7
|
from .gemini import GeminiWrapper
|
|
8
8
|
from .llm_interface import LLMInterface
|
|
9
9
|
from .openai import OpenAIWrapper
|
|
10
|
+
from .openai_responses import OpenAIResponsesWrapper
|
|
10
11
|
from .openrouter import OpenRouterWrapper
|
|
11
12
|
from .remote_ollama import RemoteOllama
|
|
12
13
|
from .ssh import SSHConnection
|
|
@@ -36,12 +37,11 @@ def supports_structured_output(model_name: str) -> bool:
|
|
|
36
37
|
# Check if this is a GPT model with version 5 or higher
|
|
37
38
|
# This handles both base models and dated variants (e.g., gpt-5, gpt-5-mini, gpt-5-2025-01-01)
|
|
38
39
|
if model_name.startswith("gpt-"):
|
|
39
|
-
# Extract the version
|
|
40
|
+
# Extract the major version after "gpt-" (gpt-5, gpt-5-mini, gpt-5.6-luna)
|
|
40
41
|
parts = model_name[4:].split("-")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return True
|
|
42
|
+
major = re.match(r"(\d+)(?:\.\d+)?$", parts[0]) if parts else None
|
|
43
|
+
if major and int(major.group(1)) >= 5:
|
|
44
|
+
return True
|
|
45
45
|
|
|
46
46
|
# Models that always support structured outputs (no date requirements)
|
|
47
47
|
base_models = {
|
|
@@ -97,6 +97,7 @@ def llm_from_config(
|
|
|
97
97
|
effort: Optional[str] = None,
|
|
98
98
|
prompt_caching: bool = True,
|
|
99
99
|
max_tool_rounds: int = 5,
|
|
100
|
+
openai_api: Literal["responses", "chat"] = "responses",
|
|
100
101
|
) -> LLMInterface:
|
|
101
102
|
"""
|
|
102
103
|
Creates and configures a language model interface based on specified provider and parameters.
|
|
@@ -119,12 +120,17 @@ def llm_from_config(
|
|
|
119
120
|
structured_outputs (Optional[bool]): Whether to override structured output support. Defaults to None.
|
|
120
121
|
thinking (Optional[Dict[str, Any]]): Extended thinking configuration forwarded to
|
|
121
122
|
`AnthropicWrapper` (e.g. {"type": "adaptive"}). Only used by the "anthropic" provider.
|
|
122
|
-
effort (Optional[str]): Effort level
|
|
123
|
-
`
|
|
123
|
+
effort (Optional[str]): Effort level forwarded to `AnthropicWrapper` as
|
|
124
|
+
`output_config.effort` (low|medium|high|xhigh|max) or to `OpenAIWrapper`
|
|
125
|
+
as `reasoning_effort` (none|low|medium|high|xhigh). Used by the
|
|
126
|
+
"anthropic" and "openai" providers.
|
|
124
127
|
prompt_caching (bool): Whether `AnthropicWrapper` should send top-level `cache_control`.
|
|
125
128
|
Defaults to True. Only used by the "anthropic" provider.
|
|
126
129
|
max_tool_rounds (int): Maximum number of tool-call round-trips forwarded to
|
|
127
130
|
`LLMInterface`. Defaults to 5. Used by the "openai" and "anthropic" providers.
|
|
131
|
+
openai_api (Literal["responses", "chat"]): Which OpenAI API the "openai"
|
|
132
|
+
provider talks to. "responses" (the default) supports reasoning
|
|
133
|
+
together with function tools; "chat" is the Chat Completions API.
|
|
128
134
|
|
|
129
135
|
Returns:
|
|
130
136
|
LLMInterface: Configured interface for interacting with the specified LLM.
|
|
@@ -154,8 +160,14 @@ def llm_from_config(
|
|
|
154
160
|
api_key = os.getenv("OPENAI_API_KEY")
|
|
155
161
|
if api_key is None:
|
|
156
162
|
raise ValueError("OPENAI_API_KEY not found in environment variables")
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
wrapper_class = (
|
|
164
|
+
OpenAIResponsesWrapper if openai_api == "responses" else OpenAIWrapper
|
|
165
|
+
)
|
|
166
|
+
wrapper = wrapper_class(
|
|
167
|
+
api_key=api_key,
|
|
168
|
+
max_tokens=max_tokens,
|
|
169
|
+
timeout=timeout,
|
|
170
|
+
reasoning_effort=effort,
|
|
159
171
|
)
|
|
160
172
|
|
|
161
173
|
support_structured_outputs = supports_structured_output(model_name)
|
|
@@ -47,6 +47,10 @@ class ModelError(Exception):
|
|
|
47
47
|
pass
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
# seconds to wait (times the attempt number) after a provider rate limit
|
|
51
|
+
RATE_LIMIT_RETRY_DELAY = 15.0
|
|
52
|
+
|
|
53
|
+
|
|
50
54
|
class LLMInterface:
|
|
51
55
|
"""
|
|
52
56
|
A unified interface for interacting with various Language Learning Models (LLMs).
|
|
@@ -180,7 +184,10 @@ class LLMInterface:
|
|
|
180
184
|
return new_model
|
|
181
185
|
|
|
182
186
|
def _execute_tool_calls(
|
|
183
|
-
self,
|
|
187
|
+
self,
|
|
188
|
+
tool_calls: List[Dict[str, Any]],
|
|
189
|
+
tools: List[Tool],
|
|
190
|
+
assistant_extra: Optional[Dict[str, Any]] = None,
|
|
184
191
|
) -> List[Dict[str, Any]]:
|
|
185
192
|
"""Execute every tool call made in a single assistant turn, as one unit.
|
|
186
193
|
|
|
@@ -199,6 +206,9 @@ class LLMInterface:
|
|
|
199
206
|
Args:
|
|
200
207
|
tool_calls (List[Dict[str, Any]]): The tool calls from the assistant's response.
|
|
201
208
|
tools (List[Tool]): The tools available for execution.
|
|
209
|
+
assistant_extra (Optional[Dict[str, Any]]): Extra keys to keep on the
|
|
210
|
+
assistant message, e.g. provider-specific items a client needs to
|
|
211
|
+
replay on the next request.
|
|
202
212
|
|
|
203
213
|
Returns:
|
|
204
214
|
List[Dict[str, Any]]: ``[assistant_message, tool_message, ...]`` - one
|
|
@@ -287,11 +297,13 @@ class LLMInterface:
|
|
|
287
297
|
}
|
|
288
298
|
)
|
|
289
299
|
|
|
290
|
-
assistant_message = {
|
|
300
|
+
assistant_message: Dict[str, Any] = {
|
|
291
301
|
"role": "assistant",
|
|
292
302
|
"content": "",
|
|
293
303
|
"tool_calls": assistant_tool_calls,
|
|
294
304
|
}
|
|
305
|
+
if assistant_extra:
|
|
306
|
+
assistant_message.update(assistant_extra)
|
|
295
307
|
|
|
296
308
|
return [assistant_message] + tool_messages
|
|
297
309
|
|
|
@@ -346,25 +358,24 @@ class LLMInterface:
|
|
|
346
358
|
|
|
347
359
|
self.logger.info("Received chat response: %s", response)
|
|
348
360
|
|
|
349
|
-
# Check for
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
and (
|
|
354
|
-
response.get("error_type") == errors.TIMEOUT
|
|
355
|
-
or response.get("error_type") == errors.CONNECTION
|
|
356
|
-
)
|
|
357
|
-
):
|
|
361
|
+
# Check for a transient error: timeouts, connection problems and
|
|
362
|
+
# rate limits are retried with a growing delay
|
|
363
|
+
error_type = response.get("error_type") if "error" in response else None
|
|
364
|
+
if error_type in (errors.TIMEOUT, errors.CONNECTION, errors.RATE_LIMIT):
|
|
358
365
|
retry_count += 1
|
|
359
366
|
if retry_count <= self.max_retries:
|
|
367
|
+
delay = self.retry_delay * retry_count
|
|
368
|
+
if error_type == errors.RATE_LIMIT:
|
|
369
|
+
# per-minute token limits need real time to clear
|
|
370
|
+
delay = max(delay, RATE_LIMIT_RETRY_DELAY * retry_count)
|
|
360
371
|
self.logger.warning(
|
|
361
372
|
"Request error (%s). Retrying (%d/%d) after %.1f seconds...",
|
|
362
373
|
response["error"],
|
|
363
374
|
retry_count,
|
|
364
375
|
self.max_retries,
|
|
365
|
-
|
|
376
|
+
delay,
|
|
366
377
|
)
|
|
367
|
-
time.sleep(
|
|
378
|
+
time.sleep(delay)
|
|
368
379
|
continue
|
|
369
380
|
else:
|
|
370
381
|
self.logger.error(
|
|
@@ -390,6 +401,7 @@ class LLMInterface:
|
|
|
390
401
|
total_tokens=usage_data.get("total_tokens", 0),
|
|
391
402
|
cached_tokens=usage_data.get("cached_tokens", 0),
|
|
392
403
|
reasoning_tokens=usage_data.get("reasoning_tokens", 0),
|
|
404
|
+
cache_creation_tokens=usage_data.get("cache_creation_tokens", 0),
|
|
393
405
|
)
|
|
394
406
|
|
|
395
407
|
return response
|
|
@@ -523,8 +535,19 @@ class LLMInterface:
|
|
|
523
535
|
|
|
524
536
|
self.logger.info("Received tool calls: %s", tool_calls)
|
|
525
537
|
# Execute all tool calls from this turn as one unit and add
|
|
526
|
-
# the results to the conversation.
|
|
527
|
-
|
|
538
|
+
# the results to the conversation. Clients that replay their
|
|
539
|
+
# own output items (the Responses API) keep them on the
|
|
540
|
+
# assistant message.
|
|
541
|
+
assistant_extra = None
|
|
542
|
+
if getattr(self.client, "keeps_provider_items", False):
|
|
543
|
+
assistant_extra = {
|
|
544
|
+
key: value
|
|
545
|
+
for key, value in response.get("message", {}).items()
|
|
546
|
+
if key not in ("content", "tool_calls")
|
|
547
|
+
}
|
|
548
|
+
tool_messages = self._execute_tool_calls(
|
|
549
|
+
tool_calls, tools or [], assistant_extra=assistant_extra
|
|
550
|
+
)
|
|
528
551
|
current_messages.extend(tool_messages)
|
|
529
552
|
|
|
530
553
|
if num_tool_rounds >= effective_max_tool_rounds:
|
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
import json
|
|
15
15
|
import logging
|
|
16
16
|
from datetime import datetime, timezone
|
|
17
|
-
from typing import Any, Dict, List, Optional
|
|
17
|
+
from typing import Any, Dict, List, Optional, Set
|
|
18
18
|
|
|
19
19
|
from ollama import ListResponse
|
|
20
|
+
from openai import pydantic_function_tool
|
|
20
21
|
from openai import (
|
|
21
22
|
APITimeoutError,
|
|
23
|
+
BadRequestError,
|
|
22
24
|
ContentFilterFinishReasonError,
|
|
23
25
|
LengthFinishReasonError,
|
|
24
26
|
OpenAI,
|
|
27
|
+
RateLimitError,
|
|
25
28
|
)
|
|
26
29
|
|
|
27
30
|
from . import errors
|
|
@@ -164,14 +167,93 @@ def convert_openai_models_to_ollama_response(openai_models_data) -> ListResponse
|
|
|
164
167
|
return ListResponse(models=ollama_models)
|
|
165
168
|
|
|
166
169
|
|
|
170
|
+
def _json_schema_response_format(schema: Any) -> Dict[str, Any]:
|
|
171
|
+
"""A strict ``json_schema`` response_format for a Pydantic model, built the
|
|
172
|
+
same way the SDK's ``parse`` helper builds it."""
|
|
173
|
+
strict_schema = pydantic_function_tool(schema)["function"]["parameters"]
|
|
174
|
+
return {
|
|
175
|
+
"type": "json_schema",
|
|
176
|
+
"json_schema": {
|
|
177
|
+
"name": schema.__name__,
|
|
178
|
+
"schema": strict_schema,
|
|
179
|
+
"strict": True,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
167
184
|
class OpenAIWrapper:
|
|
168
|
-
def __init__(
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
api_key: str,
|
|
188
|
+
max_tokens: int = 4096,
|
|
189
|
+
timeout: float = 600.0,
|
|
190
|
+
reasoning_effort: Optional[str] = None,
|
|
191
|
+
):
|
|
192
|
+
"""
|
|
193
|
+
Args:
|
|
194
|
+
api_key (str): OpenAI API key.
|
|
195
|
+
max_tokens (int): Default ``max_completion_tokens`` for requests.
|
|
196
|
+
timeout (float): Request timeout in seconds.
|
|
197
|
+
reasoning_effort (Optional[str]): Forwarded as ``reasoning_effort`` on
|
|
198
|
+
every request for reasoning models (e.g. "none", "low", "medium",
|
|
199
|
+
"high"). None leaves the model default.
|
|
200
|
+
"""
|
|
169
201
|
self.client = OpenAI(api_key=api_key, timeout=timeout)
|
|
170
202
|
self.max_tokens = max_tokens
|
|
203
|
+
self.reasoning_effort = reasoning_effort
|
|
204
|
+
# models (gpt-5.6-luna on chat completions) that only accept function
|
|
205
|
+
# tools with reasoning_effort "none"; filled in after the first rejection
|
|
206
|
+
self._effort_rejected_with_tools: Set[str] = set()
|
|
171
207
|
|
|
172
208
|
def list(self) -> ListResponse:
|
|
173
209
|
return convert_openai_models_to_ollama_response(self.client.models.list())
|
|
174
210
|
|
|
211
|
+
def _complete(
|
|
212
|
+
self,
|
|
213
|
+
api_params: Dict[str, Any],
|
|
214
|
+
tools: Optional[List[Dict[str, Any]]],
|
|
215
|
+
kwargs: Dict[str, Any],
|
|
216
|
+
):
|
|
217
|
+
"""One request. Returns (response, message, content), or a refusal dict."""
|
|
218
|
+
if "response_schema" in kwargs:
|
|
219
|
+
schema = kwargs["response_schema"]
|
|
220
|
+
strict_tools = all(
|
|
221
|
+
tool.get("function", {}).get("strict") for tool in (tools or [])
|
|
222
|
+
)
|
|
223
|
+
if strict_tools:
|
|
224
|
+
# `client.beta.chat.completions.parse` is deprecated in openai
|
|
225
|
+
# 3.x; use the stable `client.chat.completions.parse` instead.
|
|
226
|
+
response = self.client.chat.completions.parse(
|
|
227
|
+
response_format=schema,
|
|
228
|
+
**api_params,
|
|
229
|
+
)
|
|
230
|
+
message = response.choices[0].message
|
|
231
|
+
|
|
232
|
+
# Check for refusal
|
|
233
|
+
if "refusal" in message:
|
|
234
|
+
return {"refusal": message.refusal, "content": None, "done": False}
|
|
235
|
+
|
|
236
|
+
return response, message, message.parsed
|
|
237
|
+
|
|
238
|
+
# parse() refuses to run with non-strict function tools (any tool
|
|
239
|
+
# with an optional parameter). Ask for the schema as a strict
|
|
240
|
+
# json_schema response_format instead and hand the JSON text back;
|
|
241
|
+
# LLMInterface validates it against the model.
|
|
242
|
+
response = self.client.chat.completions.create(
|
|
243
|
+
response_format=_json_schema_response_format(schema),
|
|
244
|
+
**api_params,
|
|
245
|
+
)
|
|
246
|
+
message = response.choices[0].message
|
|
247
|
+
if getattr(message, "refusal", None):
|
|
248
|
+
return {"refusal": message.refusal, "content": None, "done": False}
|
|
249
|
+
return response, message, message.content
|
|
250
|
+
|
|
251
|
+
if "format" in kwargs and kwargs["format"] == "json":
|
|
252
|
+
api_params["response_format"] = {"type": "json_object"}
|
|
253
|
+
response = self.client.chat.completions.create(**api_params)
|
|
254
|
+
message = response.choices[0].message
|
|
255
|
+
return response, message, message.content
|
|
256
|
+
|
|
175
257
|
def chat(
|
|
176
258
|
self,
|
|
177
259
|
messages: List[Dict[str, Any]],
|
|
@@ -235,29 +317,40 @@ class OpenAIWrapper:
|
|
|
235
317
|
if kwargs.get("tool_choice") is not None:
|
|
236
318
|
api_params["tool_choice"] = kwargs["tool_choice"]
|
|
237
319
|
|
|
320
|
+
if tools and api_params["model"] in self._effort_rejected_with_tools:
|
|
321
|
+
api_params["reasoning_effort"] = "none"
|
|
322
|
+
elif self.reasoning_effort is not None:
|
|
323
|
+
api_params["reasoning_effort"] = self.reasoning_effort
|
|
324
|
+
|
|
238
325
|
logging.debug("API parameters: %s", api_params)
|
|
239
326
|
|
|
240
327
|
try:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
328
|
+
try:
|
|
329
|
+
completed = self._complete(api_params, tools, kwargs)
|
|
330
|
+
except BadRequestError as e:
|
|
331
|
+
message_text = str(e)
|
|
332
|
+
if (
|
|
333
|
+
tools
|
|
334
|
+
and "reasoning_effort" in message_text
|
|
335
|
+
and "not supported" in message_text
|
|
336
|
+
and api_params.get("reasoning_effort") != "none"
|
|
337
|
+
):
|
|
338
|
+
# e.g. "Function tools with reasoning_effort are not supported
|
|
339
|
+
# for gpt-5.6-luna in /v1/chat/completions. To use function
|
|
340
|
+
# tools, use /v1/responses or set reasoning_effort to 'none'."
|
|
341
|
+
logging.warning(
|
|
342
|
+
"%s only accepts function tools with reasoning_effort "
|
|
343
|
+
"'none' on chat completions; retrying that way",
|
|
344
|
+
api_params["model"],
|
|
345
|
+
)
|
|
346
|
+
self._effort_rejected_with_tools.add(api_params["model"])
|
|
347
|
+
api_params["reasoning_effort"] = "none"
|
|
348
|
+
completed = self._complete(api_params, tools, kwargs)
|
|
349
|
+
else:
|
|
350
|
+
raise
|
|
351
|
+
if isinstance(completed, dict):
|
|
352
|
+
return completed # a refusal
|
|
353
|
+
response, message, content = completed
|
|
261
354
|
|
|
262
355
|
# Log the usage details
|
|
263
356
|
usage = response.usage
|
|
@@ -288,9 +381,13 @@ class OpenAIWrapper:
|
|
|
288
381
|
"done": response.choices[0].finish_reason == "stop",
|
|
289
382
|
}
|
|
290
383
|
if usage.prompt_tokens_details:
|
|
291
|
-
return_message["usage"][
|
|
292
|
-
|
|
293
|
-
|
|
384
|
+
return_message["usage"]["cached_tokens"] = (
|
|
385
|
+
usage.prompt_tokens_details.cached_tokens or 0
|
|
386
|
+
)
|
|
387
|
+
if usage.completion_tokens_details:
|
|
388
|
+
return_message["usage"]["reasoning_tokens"] = (
|
|
389
|
+
usage.completion_tokens_details.reasoning_tokens or 0
|
|
390
|
+
)
|
|
294
391
|
# Check for tool calls
|
|
295
392
|
if message.tool_calls:
|
|
296
393
|
return_message["message"]["tool_calls"] = [
|
|
@@ -321,6 +418,14 @@ class OpenAIWrapper:
|
|
|
321
418
|
"done": False,
|
|
322
419
|
"usage": None,
|
|
323
420
|
}
|
|
421
|
+
except RateLimitError as e:
|
|
422
|
+
return {
|
|
423
|
+
"error": f"Rate limited: {e}",
|
|
424
|
+
"error_type": errors.RATE_LIMIT,
|
|
425
|
+
"content": None,
|
|
426
|
+
"done": False,
|
|
427
|
+
"usage": None,
|
|
428
|
+
}
|
|
324
429
|
except APITimeoutError:
|
|
325
430
|
# Handle the timeout error
|
|
326
431
|
return {
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Copyright 2024 Niels Provos
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
"""OpenAI Responses API wrapper.
|
|
15
|
+
|
|
16
|
+
The Responses API is where OpenAI's reasoning models expose reasoning together
|
|
17
|
+
with function tools (chat completions only allows tools with
|
|
18
|
+
``reasoning_effort="none"``). Requests are stateless (``store=False``): the whole
|
|
19
|
+
transcript is translated into input items on every round, and the items the
|
|
20
|
+
model produced in a tool-calling turn (reasoning, function calls) are replayed
|
|
21
|
+
verbatim on the next round so the model keeps its reasoning across tool calls.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from ollama import ListResponse
|
|
29
|
+
from openai import (
|
|
30
|
+
APIConnectionError,
|
|
31
|
+
APITimeoutError,
|
|
32
|
+
OpenAI,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
pydantic_function_tool,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from . import errors
|
|
38
|
+
from .openai import convert_openai_models_to_ollama_response
|
|
39
|
+
from .utils import encode_image_to_base64
|
|
40
|
+
|
|
41
|
+
# Key on an assistant message under which LLMInterface keeps the raw output items
|
|
42
|
+
# of a tool-calling turn so this wrapper can replay them.
|
|
43
|
+
PROVIDER_ITEMS = "provider_items"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def translate_tools_for_responses(
|
|
47
|
+
tools: Optional[List[Dict[str, Any]]],
|
|
48
|
+
) -> List[Dict[str, Any]]:
|
|
49
|
+
"""Chat-completions style tools (``{"type": "function", "function": {...}}``)
|
|
50
|
+
to the flat Responses API shape."""
|
|
51
|
+
translated = []
|
|
52
|
+
for tool in tools or []:
|
|
53
|
+
function = tool.get("function", tool)
|
|
54
|
+
translated.append(
|
|
55
|
+
{
|
|
56
|
+
"type": "function",
|
|
57
|
+
"name": function["name"],
|
|
58
|
+
"description": function.get("description", ""),
|
|
59
|
+
"parameters": function.get(
|
|
60
|
+
"parameters", {"type": "object", "properties": {}}
|
|
61
|
+
),
|
|
62
|
+
"strict": bool(function.get("strict", False)),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
return translated
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def translate_messages_for_responses(
|
|
69
|
+
messages: List[Dict[str, Any]],
|
|
70
|
+
) -> Tuple[Optional[str], List[Dict[str, Any]]]:
|
|
71
|
+
"""Ollama/API style messages to (instructions, input items).
|
|
72
|
+
|
|
73
|
+
System messages become the ``instructions`` string; an assistant turn with
|
|
74
|
+
tool calls becomes its replayed provider items when it has them, otherwise
|
|
75
|
+
one ``function_call`` item per call; tool messages become
|
|
76
|
+
``function_call_output`` items.
|
|
77
|
+
"""
|
|
78
|
+
instruction_parts: List[str] = []
|
|
79
|
+
items: List[Dict[str, Any]] = []
|
|
80
|
+
|
|
81
|
+
for msg in messages:
|
|
82
|
+
role = msg.get("role")
|
|
83
|
+
if role == "system":
|
|
84
|
+
if msg.get("content"):
|
|
85
|
+
instruction_parts.append(msg["content"])
|
|
86
|
+
elif role == "user":
|
|
87
|
+
if msg.get("images"):
|
|
88
|
+
content: List[Dict[str, Any]] = [
|
|
89
|
+
{"type": "input_text", "text": msg.get("content", "")}
|
|
90
|
+
]
|
|
91
|
+
for image in msg["images"]:
|
|
92
|
+
image_type = image.split(".")[-1].lower()
|
|
93
|
+
content.append(
|
|
94
|
+
{
|
|
95
|
+
"type": "input_image",
|
|
96
|
+
"image_url": f"data:image/{image_type};base64,"
|
|
97
|
+
+ encode_image_to_base64(image),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
items.append({"role": "user", "content": content})
|
|
101
|
+
else:
|
|
102
|
+
items.append({"role": "user", "content": msg.get("content", "")})
|
|
103
|
+
elif role == "assistant" and msg.get("tool_calls"):
|
|
104
|
+
if msg.get(PROVIDER_ITEMS):
|
|
105
|
+
items.extend(msg[PROVIDER_ITEMS])
|
|
106
|
+
continue
|
|
107
|
+
if msg.get("content"):
|
|
108
|
+
items.append({"role": "assistant", "content": msg["content"]})
|
|
109
|
+
for tool_call in msg["tool_calls"]:
|
|
110
|
+
call_id = tool_call.get("id")
|
|
111
|
+
if not call_id:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"An assistant tool call must carry the id its result "
|
|
114
|
+
f"answers; got {tool_call!r}"
|
|
115
|
+
)
|
|
116
|
+
function = tool_call.get("function", tool_call)
|
|
117
|
+
arguments = function.get("arguments", {})
|
|
118
|
+
if not isinstance(arguments, str):
|
|
119
|
+
arguments = json.dumps(arguments)
|
|
120
|
+
items.append(
|
|
121
|
+
{
|
|
122
|
+
"type": "function_call",
|
|
123
|
+
"call_id": call_id,
|
|
124
|
+
"name": function["name"],
|
|
125
|
+
"arguments": arguments,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
elif role == "tool":
|
|
129
|
+
if not msg.get("tool_call_id"):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"A tool message must carry the tool_call_id it answers; "
|
|
132
|
+
f"got {msg!r}"
|
|
133
|
+
)
|
|
134
|
+
items.append(
|
|
135
|
+
{
|
|
136
|
+
"type": "function_call_output",
|
|
137
|
+
"call_id": msg["tool_call_id"],
|
|
138
|
+
"output": str(msg.get("content", "")),
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
elif role == "assistant":
|
|
142
|
+
items.append({"role": "assistant", "content": msg.get("content", "")})
|
|
143
|
+
else:
|
|
144
|
+
raise ValueError(f"Unsupported message role: {role!r}")
|
|
145
|
+
|
|
146
|
+
instructions = "\n\n".join(instruction_parts) or None
|
|
147
|
+
return instructions, items
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _json_schema_text_format(schema: Any) -> Dict[str, Any]:
|
|
151
|
+
strict_schema = pydantic_function_tool(schema)["function"]["parameters"]
|
|
152
|
+
return {
|
|
153
|
+
"format": {
|
|
154
|
+
"type": "json_schema",
|
|
155
|
+
"name": schema.__name__,
|
|
156
|
+
"schema": strict_schema,
|
|
157
|
+
"strict": True,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class OpenAIResponsesWrapper:
|
|
163
|
+
"""Client for the OpenAI Responses API with the same ``chat()`` contract as
|
|
164
|
+
the other wrappers."""
|
|
165
|
+
|
|
166
|
+
# LLMInterface keeps the raw output items of tool-calling turns on the
|
|
167
|
+
# transcript for clients that declare this
|
|
168
|
+
keeps_provider_items = True
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
api_key: str,
|
|
173
|
+
max_tokens: int = 4096,
|
|
174
|
+
timeout: float = 600.0,
|
|
175
|
+
reasoning_effort: Optional[str] = None,
|
|
176
|
+
):
|
|
177
|
+
"""
|
|
178
|
+
Args:
|
|
179
|
+
api_key (str): OpenAI API key.
|
|
180
|
+
max_tokens (int): Default ``max_output_tokens`` for requests.
|
|
181
|
+
timeout (float): Request timeout in seconds.
|
|
182
|
+
reasoning_effort (Optional[str]): ``reasoning.effort`` for every
|
|
183
|
+
request ("none", "low", "medium", "high", ...). None leaves the
|
|
184
|
+
model default.
|
|
185
|
+
"""
|
|
186
|
+
self.client = OpenAI(api_key=api_key, timeout=timeout)
|
|
187
|
+
self.max_tokens = max_tokens
|
|
188
|
+
self.reasoning_effort = reasoning_effort
|
|
189
|
+
|
|
190
|
+
def list(self) -> ListResponse:
|
|
191
|
+
return convert_openai_models_to_ollama_response(self.client.models.list())
|
|
192
|
+
|
|
193
|
+
def chat(
|
|
194
|
+
self,
|
|
195
|
+
messages: List[Dict[str, Any]],
|
|
196
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
197
|
+
**kwargs,
|
|
198
|
+
) -> Dict[str, Any]:
|
|
199
|
+
"""
|
|
200
|
+
One request to the Responses API.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
messages: Conversation in the shared message format (system, user,
|
|
204
|
+
assistant with optional tool_calls, tool).
|
|
205
|
+
tools: Tools in the ``{"type": "function", "function": {...}}`` shape.
|
|
206
|
+
**kwargs: ``model``, ``max_tokens``, ``options`` (temperature),
|
|
207
|
+
``response_schema`` (Pydantic model: strict json_schema output),
|
|
208
|
+
``format`` ("json"), ``tool_choice``.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The shared response dict: ``message`` (content, tool_calls,
|
|
212
|
+
provider_items), ``usage``, ``done``; or ``error``/``refusal`` entries.
|
|
213
|
+
"""
|
|
214
|
+
instructions, input_items = translate_messages_for_responses(messages)
|
|
215
|
+
|
|
216
|
+
params: Dict[str, Any] = {
|
|
217
|
+
"model": kwargs.get("model", "gpt-5"),
|
|
218
|
+
"input": input_items,
|
|
219
|
+
"max_output_tokens": kwargs.get("max_tokens", self.max_tokens),
|
|
220
|
+
# stateless: the transcript is replayed in full on every round
|
|
221
|
+
"store": False,
|
|
222
|
+
}
|
|
223
|
+
if instructions:
|
|
224
|
+
params["instructions"] = instructions
|
|
225
|
+
if tools:
|
|
226
|
+
params["tools"] = translate_tools_for_responses(tools)
|
|
227
|
+
if kwargs.get("tool_choice") is not None:
|
|
228
|
+
params["tool_choice"] = kwargs["tool_choice"]
|
|
229
|
+
if "options" in kwargs and "temperature" in kwargs["options"]:
|
|
230
|
+
params["temperature"] = kwargs["options"]["temperature"]
|
|
231
|
+
if self.reasoning_effort is not None:
|
|
232
|
+
params["reasoning"] = {"effort": self.reasoning_effort}
|
|
233
|
+
if self.reasoning_effort != "none":
|
|
234
|
+
# reasoning items can only be replayed with their encrypted content
|
|
235
|
+
params["include"] = ["reasoning.encrypted_content"]
|
|
236
|
+
if "response_schema" in kwargs:
|
|
237
|
+
params["text"] = _json_schema_text_format(kwargs["response_schema"])
|
|
238
|
+
elif kwargs.get("format") == "json":
|
|
239
|
+
params["text"] = {"format": {"type": "json_object"}}
|
|
240
|
+
|
|
241
|
+
logging.debug("Responses API parameters: %s", params)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
response = self.client.responses.create(**params)
|
|
245
|
+
except APITimeoutError:
|
|
246
|
+
return {
|
|
247
|
+
"error": "Request timed out.",
|
|
248
|
+
"error_type": errors.TIMEOUT,
|
|
249
|
+
"content": None,
|
|
250
|
+
"done": False,
|
|
251
|
+
"usage": None,
|
|
252
|
+
}
|
|
253
|
+
except RateLimitError as e:
|
|
254
|
+
return {
|
|
255
|
+
"error": f"Rate limited: {e}",
|
|
256
|
+
"error_type": errors.RATE_LIMIT,
|
|
257
|
+
"content": None,
|
|
258
|
+
"done": False,
|
|
259
|
+
"usage": None,
|
|
260
|
+
}
|
|
261
|
+
except APIConnectionError as e:
|
|
262
|
+
return {
|
|
263
|
+
"error": f"Connection error: {e}",
|
|
264
|
+
"error_type": errors.CONNECTION,
|
|
265
|
+
"content": None,
|
|
266
|
+
"done": False,
|
|
267
|
+
"usage": None,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return self._translate_response(response)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _translate_response(response: Any) -> Dict[str, Any]:
|
|
274
|
+
output = list(getattr(response, "output", None) or [])
|
|
275
|
+
tool_calls: List[Dict[str, Any]] = []
|
|
276
|
+
texts: List[str] = []
|
|
277
|
+
refusal: Optional[str] = None
|
|
278
|
+
for item in output:
|
|
279
|
+
item_type = getattr(item, "type", None)
|
|
280
|
+
if item_type == "function_call":
|
|
281
|
+
tool_calls.append(
|
|
282
|
+
{"id": item.call_id, "name": item.name, "arguments": item.arguments}
|
|
283
|
+
)
|
|
284
|
+
elif item_type == "message":
|
|
285
|
+
for part in getattr(item, "content", None) or []:
|
|
286
|
+
part_type = getattr(part, "type", None)
|
|
287
|
+
if part_type == "output_text":
|
|
288
|
+
texts.append(part.text)
|
|
289
|
+
elif part_type == "refusal":
|
|
290
|
+
refusal = part.refusal
|
|
291
|
+
|
|
292
|
+
usage = response.usage
|
|
293
|
+
input_details = getattr(usage, "input_tokens_details", None)
|
|
294
|
+
output_details = getattr(usage, "output_tokens_details", None)
|
|
295
|
+
usage_info = {
|
|
296
|
+
"prompt_tokens": usage.input_tokens,
|
|
297
|
+
"completion_tokens": usage.output_tokens,
|
|
298
|
+
"total_tokens": usage.total_tokens,
|
|
299
|
+
"cached_tokens": _usage_int(getattr(input_details, "cached_tokens", 0)),
|
|
300
|
+
"reasoning_tokens": _usage_int(
|
|
301
|
+
getattr(output_details, "reasoning_tokens", 0)
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
status = getattr(response, "status", None)
|
|
306
|
+
if status == "incomplete":
|
|
307
|
+
details = getattr(response, "incomplete_details", None)
|
|
308
|
+
reason = getattr(details, "reason", None)
|
|
309
|
+
if reason == "max_output_tokens":
|
|
310
|
+
return {
|
|
311
|
+
"error": "Response exceeded the maximum allowed length.",
|
|
312
|
+
"error_type": errors.LENGTH,
|
|
313
|
+
"content": None,
|
|
314
|
+
"done": False,
|
|
315
|
+
"usage": usage_info,
|
|
316
|
+
}
|
|
317
|
+
if reason == "content_filter":
|
|
318
|
+
return {
|
|
319
|
+
"error": "Content was rejected by the content filter.",
|
|
320
|
+
"error_type": errors.CONTENT_FILTER,
|
|
321
|
+
"content": None,
|
|
322
|
+
"done": False,
|
|
323
|
+
"usage": usage_info,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if refusal is not None and not tool_calls:
|
|
327
|
+
return {
|
|
328
|
+
"refusal": refusal,
|
|
329
|
+
"content": None,
|
|
330
|
+
"done": False,
|
|
331
|
+
"usage": usage_info,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
message: Dict[str, Any] = {"content": "".join(texts)}
|
|
335
|
+
if tool_calls:
|
|
336
|
+
message["tool_calls"] = tool_calls
|
|
337
|
+
# everything the model emitted this turn (reasoning, calls, text),
|
|
338
|
+
# replayed verbatim on the next request
|
|
339
|
+
message[PROVIDER_ITEMS] = [
|
|
340
|
+
item.model_dump(exclude_none=True) for item in output
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"message": message,
|
|
345
|
+
"usage": usage_info,
|
|
346
|
+
"done": status == "completed" and not tool_calls,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _usage_int(value: Any) -> int:
|
|
351
|
+
return value if isinstance(value, int) else 0
|
|
@@ -27,14 +27,21 @@ class TokenUsage(BaseModel):
|
|
|
27
27
|
prompt_tokens (int): Number of tokens used in the prompt/input
|
|
28
28
|
completion_tokens (int): Number of tokens generated in the response
|
|
29
29
|
total_tokens (int): Total tokens used (prompt + completion)
|
|
30
|
-
cached_tokens (int): Number of tokens
|
|
31
|
-
|
|
30
|
+
cached_tokens (int): Number of prompt tokens read from the provider's prompt
|
|
31
|
+
cache (if supported); they are billed at the cache-read rate
|
|
32
|
+
cache_creation_tokens (int): Number of prompt tokens written to the provider's
|
|
33
|
+
prompt cache (if supported); billed at the cache-write rate
|
|
34
|
+
reasoning_tokens (int): Number of tokens used for reasoning (if supported).
|
|
35
|
+
Reasoning tokens are part of completion_tokens. For Anthropic this is an
|
|
36
|
+
estimate: output tokens not accounted for by the visible text and tool calls
|
|
37
|
+
when the response contains thinking blocks
|
|
32
38
|
"""
|
|
33
39
|
|
|
34
40
|
prompt_tokens: int = 0
|
|
35
41
|
completion_tokens: int = 0
|
|
36
42
|
total_tokens: int = 0
|
|
37
43
|
cached_tokens: int = 0
|
|
44
|
+
cache_creation_tokens: int = 0
|
|
38
45
|
reasoning_tokens: int = 0
|
|
39
46
|
|
|
40
47
|
def update(
|
|
@@ -44,6 +51,7 @@ class TokenUsage(BaseModel):
|
|
|
44
51
|
total_tokens: int = 0,
|
|
45
52
|
cached_tokens: int = 0,
|
|
46
53
|
reasoning_tokens: int = 0,
|
|
54
|
+
cache_creation_tokens: int = 0,
|
|
47
55
|
):
|
|
48
56
|
"""
|
|
49
57
|
Updates token usage statistics.
|
|
@@ -54,6 +62,7 @@ class TokenUsage(BaseModel):
|
|
|
54
62
|
total_tokens (int): Total tokens used
|
|
55
63
|
cached_tokens (int): Number of tokens from cache
|
|
56
64
|
reasoning_tokens (int): Number of reasoning tokens
|
|
65
|
+
cache_creation_tokens (int): Number of tokens written to the prompt cache
|
|
57
66
|
**kwargs: Additional provider-specific metrics
|
|
58
67
|
"""
|
|
59
68
|
self.prompt_tokens += prompt_tokens
|
|
@@ -66,6 +75,7 @@ class TokenUsage(BaseModel):
|
|
|
66
75
|
self.total_tokens += prompt_tokens + completion_tokens
|
|
67
76
|
|
|
68
77
|
self.cached_tokens += cached_tokens
|
|
78
|
+
self.cache_creation_tokens += cache_creation_tokens
|
|
69
79
|
self.reasoning_tokens += reasoning_tokens
|
|
70
80
|
|
|
71
81
|
def reset(self):
|
|
@@ -74,6 +84,7 @@ class TokenUsage(BaseModel):
|
|
|
74
84
|
self.completion_tokens = 0
|
|
75
85
|
self.total_tokens = 0
|
|
76
86
|
self.cached_tokens = 0
|
|
87
|
+
self.cache_creation_tokens = 0
|
|
77
88
|
self.reasoning_tokens = 0
|
|
78
89
|
|
|
79
90
|
def get_all_stats(self) -> Dict[str, int]:
|
|
@@ -92,6 +103,8 @@ class TokenUsage(BaseModel):
|
|
|
92
103
|
# Only include non-zero values for optional fields
|
|
93
104
|
if self.cached_tokens > 0:
|
|
94
105
|
stats["cached_tokens"] = self.cached_tokens
|
|
106
|
+
if self.cache_creation_tokens > 0:
|
|
107
|
+
stats["cache_creation_tokens"] = self.cache_creation_tokens
|
|
95
108
|
if self.reasoning_tokens > 0:
|
|
96
109
|
stats["reasoning_tokens"] = self.reasoning_tokens
|
|
97
110
|
|
|
@@ -102,6 +115,8 @@ class TokenUsage(BaseModel):
|
|
|
102
115
|
result = f"Token usage: {self.total_tokens} total ({self.prompt_tokens} prompt, {self.completion_tokens} completion)"
|
|
103
116
|
if self.cached_tokens > 0:
|
|
104
117
|
result += f", {self.cached_tokens} cached"
|
|
118
|
+
if self.cache_creation_tokens > 0:
|
|
119
|
+
result += f", {self.cache_creation_tokens} cache writes"
|
|
105
120
|
if self.reasoning_tokens > 0:
|
|
106
121
|
result += f", {self.reasoning_tokens} reasoning"
|
|
107
122
|
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|