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.
Files changed (23) hide show
  1. {llm_interface-0.2.0 → llm_interface-0.2.2}/PKG-INFO +1 -1
  2. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/__init__.py +1 -1
  3. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/anthropic.py +30 -0
  4. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/errors.py +1 -0
  5. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/llm_config.py +21 -9
  6. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/llm_interface.py +38 -15
  7. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/openai.py +130 -25
  8. llm_interface-0.2.2/llm_interface/openai_responses.py +351 -0
  9. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/token_usage.py +17 -2
  10. {llm_interface-0.2.0 → llm_interface-0.2.2}/pyproject.toml +1 -1
  11. {llm_interface-0.2.0 → llm_interface-0.2.2}/LICENSE +0 -0
  12. {llm_interface-0.2.0 → llm_interface-0.2.2}/README.md +0 -0
  13. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/gemini.py +0 -0
  14. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/llm_tool.py +0 -0
  15. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/ollama.py +0 -0
  16. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/openrouter.py +0 -0
  17. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/pydantic_output_parser.py +0 -0
  18. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/remote_ollama.py +0 -0
  19. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/ssh.py +0 -0
  20. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/testing/__init__.py +0 -0
  21. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/testing/helpers.py +0 -0
  22. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/testing/mock_llm.py +0 -0
  23. {llm_interface-0.2.0 → llm_interface-0.2.2}/llm_interface/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-interface
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A flexible interface for working with various LLM providers
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -5,7 +5,7 @@ from .llm_interface import LLMInterface, ModelError
5
5
  from .llm_tool import Tool, tool
6
6
  from .token_usage import TokenUsage
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.2.2"
9
9
  __all__ = [
10
10
  "LLMInterface",
11
11
  "llm_from_config",
@@ -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":
@@ -4,3 +4,4 @@ CONTENT_FILTER = "content_filter"
4
4
  PROVIDER_SPECIFIC = "provider_specific"
5
5
  HTTP = "http"
6
6
  CONNECTION = "connection"
7
+ RATE_LIMIT = "rate_limit"
@@ -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 number after "gpt-"
40
+ # Extract the major version after "gpt-" (gpt-5, gpt-5-mini, gpt-5.6-luna)
40
41
  parts = model_name[4:].split("-")
41
- if parts and parts[0].isdigit():
42
- version = int(parts[0])
43
- if version >= 5:
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 (low|medium|high|xhigh|max) forwarded to
123
- `AnthropicWrapper`. Only used by the "anthropic" provider.
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
- wrapper = OpenAIWrapper(
158
- api_key=api_key, max_tokens=max_tokens, timeout=timeout
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, tool_calls: List[Dict[str, Any]], tools: List[Tool]
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 timeout error
350
- if (
351
- "error" in response
352
- and "error_type" in response
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
- self.retry_delay * retry_count,
376
+ delay,
366
377
  )
367
- time.sleep(self.retry_delay * retry_count)
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
- tool_messages = self._execute_tool_calls(tool_calls, tools or [])
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__(self, api_key: str, max_tokens: int = 4096, timeout: float = 600.0):
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
- if "response_schema" in kwargs:
242
- # `client.beta.chat.completions.parse` is deprecated in openai
243
- # 3.x; use the stable `client.chat.completions.parse` instead.
244
- response = self.client.chat.completions.parse(
245
- response_format=kwargs.get("response_schema"),
246
- **api_params,
247
- )
248
- message = response.choices[0].message
249
-
250
- # Check for refusal
251
- if "refusal" in message:
252
- return {"refusal": message.refusal, "content": None, "done": False}
253
-
254
- content = message.parsed
255
- else:
256
- if "format" in kwargs and kwargs["format"] == "json":
257
- api_params["response_format"] = {"type": "json_object"}
258
- response = self.client.chat.completions.create(**api_params)
259
- message = response.choices[0].message
260
- content = message.content
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
- "cached_tokens"
293
- ] = usage.prompt_tokens_details.cached_tokens
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 retrieved from cache (if supported)
31
- reasoning_tokens (int): Number of tokens used for reasoning (if supported)
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "llm-interface"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "A flexible interface for working with various LLM providers"
5
5
  authors = ["Niels Provos <provos@gmail.com>"]
6
6
  license = "Apache-2.0"
File without changes
File without changes