ccs-llmconnector 1.0.6__py3-none-any.whl → 1.1.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.
@@ -4,99 +4,164 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import mimetypes
7
- from pathlib import Path
8
- import logging
9
- from typing import Optional, Sequence, Union
10
-
11
- from openai import OpenAI
12
-
13
- try:
14
- from openai import APIError as OpenAIError # type: ignore
15
- except ImportError: # pragma: no cover - fallback for older SDKs
16
- from openai.error import OpenAIError # type: ignore
17
-
18
- ImageInput = Union[str, Path]
19
- logger = logging.getLogger(__name__)
7
+ from pathlib import Path
8
+ import logging
9
+ from typing import Optional, Sequence
10
+
11
+ from openai import OpenAI
12
+
13
+ from .types import ImageInput, MessageSequence, normalize_messages
14
+ from .utils import clamp_retries, run_sync_in_thread, run_with_retries
15
+
16
+ logger = logging.getLogger(__name__)
20
17
 
21
18
 
22
19
  class OpenAIResponsesClient:
23
20
  """Convenience wrapper around the OpenAI Responses API."""
24
21
 
25
- def generate_response(
26
- self,
27
- *,
28
- api_key: str,
29
- prompt: str,
30
- model: str,
31
- max_tokens: int = 32000,
32
- reasoning_effort: Optional[str] = None,
33
- images: Optional[Sequence[ImageInput]] = None,
34
- ) -> str:
35
- """Generate a response from the specified model.
36
-
37
- Args:
38
- api_key: Secret key used to authenticate with OpenAI.
39
- prompt: Natural-language instruction or query for the model.
40
- model: Identifier of the OpenAI model to target (for example, ``"gpt-4o"``).
41
- max_tokens: Cap for tokens across the entire exchange, defaults to 32000.
42
- reasoning_effort: Optional reasoning effort hint (``"low"``, ``"medium"``, or ``"high"``).
43
- images: Optional collection of image references (local paths or URLs).
44
-
45
- Returns:
46
- The text output produced by the model.
47
-
22
+ def generate_response(
23
+ self,
24
+ *,
25
+ api_key: str,
26
+ prompt: Optional[str] = None,
27
+ model: str,
28
+ max_tokens: int = 32000,
29
+ reasoning_effort: Optional[str] = None,
30
+ images: Optional[Sequence[ImageInput]] = None,
31
+ messages: Optional[MessageSequence] = None,
32
+ request_id: Optional[str] = None,
33
+ timeout_s: Optional[float] = None,
34
+ max_retries: Optional[int] = None,
35
+ retry_backoff_s: float = 0.5,
36
+ ) -> str:
37
+ """Generate a response from the specified model.
38
+
39
+ Args:
40
+ api_key: Secret key used to authenticate with OpenAI.
41
+ prompt: Natural-language instruction or query for the model.
42
+ model: Identifier of the OpenAI model to target (for example, ``"gpt-4o"``).
43
+ max_tokens: Cap for tokens across the entire exchange, defaults to 32000.
44
+ reasoning_effort: Optional reasoning effort hint (``"low"``, ``"medium"``, or ``"high"``).
45
+ images: Optional collection of image references (local paths or URLs).
46
+ messages: Optional list of chat-style messages (role/content).
47
+ request_id: Optional request identifier for tracing/logging.
48
+ timeout_s: Optional request timeout in seconds.
49
+ max_retries: Optional retry count for transient failures.
50
+ retry_backoff_s: Base delay (seconds) for exponential backoff between retries.
51
+
52
+ Returns:
53
+ The text output produced by the model.
54
+
48
55
  Raises:
49
56
  ValueError: If required arguments are missing or the request payload is empty.
50
57
  OpenAIError: If the underlying OpenAI request fails.
51
- """
52
- if not api_key:
53
- raise ValueError("api_key must be provided.")
54
- if not prompt and not images:
55
- raise ValueError("At least one of prompt or images must be provided.")
56
- if not model:
57
- raise ValueError("model must be provided.")
58
-
59
- client = OpenAI(api_key=api_key)
60
-
61
- content_blocks = []
62
- if prompt:
63
- content_blocks.append({"type": "input_text", "text": prompt})
64
-
65
- if images:
66
- for image in images:
67
- content_blocks.append(self._to_image_block(image))
68
-
69
- if not content_blocks:
70
- raise ValueError("No content provided for response generation.")
71
-
72
- request_payload = {
73
- "model": model,
74
- "input": [
75
- {
76
- "role": "user",
77
- "content": content_blocks,
78
- }
79
- ],
80
- "max_output_tokens": max_tokens,
81
- }
82
-
83
- if reasoning_effort:
84
- request_payload["reasoning"] = {"effort": reasoning_effort}
85
-
86
- try:
87
- response = client.responses.create(**request_payload)
88
- except Exception as exc: # Log and re-raise to preserve default behavior
89
- logger.exception("OpenAI Responses API request failed: %s", exc)
90
- raise
91
-
92
- output_text = response.output_text
93
- logger.info(
94
- "OpenAI generate_response succeeded: model=%s images=%d text_len=%d",
95
- model,
96
- len(images or []),
97
- len(output_text or ""),
98
- )
99
- return output_text
58
+ """
59
+ if not api_key:
60
+ raise ValueError("api_key must be provided.")
61
+ if not prompt and not messages and not images:
62
+ raise ValueError("At least one of prompt, messages, or images must be provided.")
63
+ if not model:
64
+ raise ValueError("model must be provided.")
65
+
66
+ normalized_messages = normalize_messages(prompt=prompt, messages=messages)
67
+ content_messages: list[dict] = []
68
+ for message in normalized_messages:
69
+ content_blocks = []
70
+ if message["content"]:
71
+ content_blocks.append({"type": "input_text", "text": message["content"]})
72
+ content_messages.append({"role": message["role"], "content": content_blocks})
73
+
74
+ if images:
75
+ image_blocks = [self._to_image_block(image) for image in images]
76
+ target_index = next(
77
+ (
78
+ index
79
+ for index in range(len(content_messages) - 1, -1, -1)
80
+ if content_messages[index]["role"] == "user"
81
+ ),
82
+ None,
83
+ )
84
+ if target_index is None:
85
+ content_messages.append({"role": "user", "content": image_blocks})
86
+ else:
87
+ content_messages[target_index]["content"].extend(image_blocks)
88
+
89
+ if not any(message["content"] for message in content_messages):
90
+ raise ValueError("No content provided for response generation.")
91
+
92
+ request_payload = {
93
+ "model": model,
94
+ "input": content_messages,
95
+ "max_output_tokens": max_tokens,
96
+ }
97
+
98
+ if reasoning_effort:
99
+ request_payload["reasoning"] = {"effort": reasoning_effort}
100
+
101
+ retry_count = clamp_retries(max_retries)
102
+
103
+ def _run_request() -> str:
104
+ client_kwargs = {"api_key": api_key}
105
+ if timeout_s is not None:
106
+ client_kwargs["timeout"] = timeout_s
107
+ client = OpenAI(**client_kwargs)
108
+ try:
109
+ response = client.responses.create(**request_payload)
110
+ except Exception as exc: # Log and re-raise to preserve default behavior
111
+ logger.exception(
112
+ "OpenAI Responses API request failed: %s request_id=%s",
113
+ exc,
114
+ request_id,
115
+ )
116
+ raise
117
+
118
+ output_text = response.output_text
119
+ logger.info(
120
+ "OpenAI generate_response succeeded: model=%s images=%d text_len=%d request_id=%s",
121
+ model,
122
+ len(images or []),
123
+ len(output_text or ""),
124
+ request_id,
125
+ )
126
+ return output_text
127
+
128
+ return run_with_retries(
129
+ func=_run_request,
130
+ max_retries=retry_count,
131
+ retry_backoff_s=retry_backoff_s,
132
+ request_id=request_id,
133
+ )
134
+
135
+ async def async_generate_response(
136
+ self,
137
+ *,
138
+ api_key: str,
139
+ prompt: Optional[str] = None,
140
+ model: str,
141
+ max_tokens: int = 32000,
142
+ reasoning_effort: Optional[str] = None,
143
+ images: Optional[Sequence[ImageInput]] = None,
144
+ messages: Optional[MessageSequence] = None,
145
+ request_id: Optional[str] = None,
146
+ timeout_s: Optional[float] = None,
147
+ max_retries: Optional[int] = None,
148
+ retry_backoff_s: float = 0.5,
149
+ ) -> str:
150
+ return await run_sync_in_thread(
151
+ lambda: self.generate_response(
152
+ api_key=api_key,
153
+ prompt=prompt,
154
+ model=model,
155
+ max_tokens=max_tokens,
156
+ reasoning_effort=reasoning_effort,
157
+ images=images,
158
+ messages=messages,
159
+ request_id=request_id,
160
+ timeout_s=timeout_s,
161
+ max_retries=max_retries,
162
+ retry_backoff_s=retry_backoff_s,
163
+ )
164
+ )
100
165
 
101
166
  @staticmethod
102
167
  def _to_image_block(image: ImageInput) -> dict:
@@ -118,11 +183,11 @@ class OpenAIResponsesClient:
118
183
  "image_url": _encode_image_path(Path(image)),
119
184
  }
120
185
 
121
- def generate_image(
122
- self,
123
- *,
124
- api_key: str,
125
- prompt: str,
186
+ def generate_image(
187
+ self,
188
+ *,
189
+ api_key: str,
190
+ prompt: str,
126
191
  model: str,
127
192
  image_size: Optional[str] = None,
128
193
  aspect_ratio: Optional[str] = None,
@@ -130,40 +195,107 @@ class OpenAIResponsesClient:
130
195
  ) -> bytes:
131
196
  """Generate an image using the OpenAI API.
132
197
 
133
- Raises:
134
- NotImplementedError: This method is not yet implemented for OpenAI.
135
- """
136
- raise NotImplementedError("Image generation is not implemented for OpenAI.")
137
-
138
- def list_models(self, *, api_key: str) -> list[dict[str, Optional[str]]]:
139
- """Return the models available to the authenticated OpenAI account."""
140
- if not api_key:
141
- raise ValueError("api_key must be provided.")
142
-
143
- client = OpenAI(api_key=api_key)
144
- try:
145
- response = client.models.list()
146
- except Exception as exc:
147
- logger.exception("OpenAI list models failed: %s", exc)
148
- raise
149
- data = getattr(response, "data", []) or []
150
-
151
- models: list[dict[str, Optional[str]]] = []
152
- for model in data:
153
- model_id = getattr(model, "id", None)
154
- if model_id is None and isinstance(model, dict):
155
- model_id = model.get("id")
156
- if not model_id:
157
- continue
158
-
159
- display_name = getattr(model, "display_name", None)
160
- if display_name is None and isinstance(model, dict):
161
- display_name = model.get("display_name")
162
-
163
- models.append({"id": model_id, "display_name": display_name})
164
-
165
- logger.info("OpenAI list_models succeeded: count=%d", len(models))
166
- return models
198
+ Raises:
199
+ NotImplementedError: This method is not yet implemented for OpenAI.
200
+ """
201
+ raise NotImplementedError("Image generation is not implemented for OpenAI.")
202
+
203
+ async def async_generate_image(
204
+ self,
205
+ *,
206
+ api_key: str,
207
+ prompt: str,
208
+ model: str,
209
+ image_size: Optional[str] = None,
210
+ aspect_ratio: Optional[str] = None,
211
+ image: Optional[ImageInput] = None,
212
+ ) -> bytes:
213
+ return await run_sync_in_thread(
214
+ lambda: self.generate_image(
215
+ api_key=api_key,
216
+ prompt=prompt,
217
+ model=model,
218
+ image_size=image_size,
219
+ aspect_ratio=aspect_ratio,
220
+ image=image,
221
+ )
222
+ )
223
+
224
+ def list_models(
225
+ self,
226
+ *,
227
+ api_key: str,
228
+ request_id: Optional[str] = None,
229
+ timeout_s: Optional[float] = None,
230
+ max_retries: Optional[int] = None,
231
+ retry_backoff_s: float = 0.5,
232
+ ) -> list[dict[str, Optional[str]]]:
233
+ """Return the models available to the authenticated OpenAI account."""
234
+ if not api_key:
235
+ raise ValueError("api_key must be provided.")
236
+
237
+ retry_count = clamp_retries(max_retries)
238
+
239
+ def _run_request() -> list[dict[str, Optional[str]]]:
240
+ client_kwargs = {"api_key": api_key}
241
+ if timeout_s is not None:
242
+ client_kwargs["timeout"] = timeout_s
243
+ client = OpenAI(**client_kwargs)
244
+ try:
245
+ response = client.models.list()
246
+ except Exception as exc:
247
+ logger.exception(
248
+ "OpenAI list models failed: %s request_id=%s", exc, request_id
249
+ )
250
+ raise
251
+ data = getattr(response, "data", []) or []
252
+
253
+ models: list[dict[str, Optional[str]]] = []
254
+ for model in data:
255
+ model_id = getattr(model, "id", None)
256
+ if model_id is None and isinstance(model, dict):
257
+ model_id = model.get("id")
258
+ if not model_id:
259
+ continue
260
+
261
+ display_name = getattr(model, "display_name", None)
262
+ if display_name is None and isinstance(model, dict):
263
+ display_name = model.get("display_name")
264
+
265
+ models.append({"id": model_id, "display_name": display_name})
266
+
267
+ logger.info(
268
+ "OpenAI list_models succeeded: count=%d request_id=%s",
269
+ len(models),
270
+ request_id,
271
+ )
272
+ return models
273
+
274
+ return run_with_retries(
275
+ func=_run_request,
276
+ max_retries=retry_count,
277
+ retry_backoff_s=retry_backoff_s,
278
+ request_id=request_id,
279
+ )
280
+
281
+ async def async_list_models(
282
+ self,
283
+ *,
284
+ api_key: str,
285
+ request_id: Optional[str] = None,
286
+ timeout_s: Optional[float] = None,
287
+ max_retries: Optional[int] = None,
288
+ retry_backoff_s: float = 0.5,
289
+ ) -> list[dict[str, Optional[str]]]:
290
+ return await run_sync_in_thread(
291
+ lambda: self.list_models(
292
+ api_key=api_key,
293
+ request_id=request_id,
294
+ timeout_s=timeout_s,
295
+ max_retries=max_retries,
296
+ retry_backoff_s=retry_backoff_s,
297
+ )
298
+ )
167
299
 
168
300
 
169
301
  def _encode_image_path(path: Path) -> str:
llmconnector/types.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Sequence, TypedDict, Union
5
+
6
+ ImageInput = Union[str, Path]
7
+
8
+
9
+ class Message(TypedDict):
10
+ role: str
11
+ content: str
12
+
13
+
14
+ MessageSequence = Sequence[Message]
15
+
16
+
17
+ class RequestOptions(TypedDict, total=False):
18
+ request_id: str
19
+ timeout_s: float
20
+ max_retries: int
21
+ retry_backoff_s: float
22
+ reasoning_effort: str
23
+ max_tokens: int
24
+ images: Sequence[ImageInput]
25
+ messages: MessageSequence
26
+
27
+
28
+ def normalize_messages(
29
+ *,
30
+ prompt: Optional[str],
31
+ messages: Optional[MessageSequence],
32
+ ) -> list[Message]:
33
+ result: list[Message] = []
34
+ if messages:
35
+ for message in messages:
36
+ role = message.get("role")
37
+ content = message.get("content")
38
+ if not isinstance(role, str) or not role:
39
+ raise ValueError("message role must be a non-empty string")
40
+ if content is None:
41
+ content = ""
42
+ if not isinstance(content, str):
43
+ raise ValueError("message content must be a string")
44
+ result.append({"role": role, "content": content})
45
+
46
+ if prompt:
47
+ result.append({"role": "user", "content": prompt})
48
+
49
+ return result
llmconnector/utils.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from typing import Awaitable, Callable, TypeVar
7
+
8
+ T = TypeVar("T")
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def clamp_retries(max_retries: int | None) -> int:
13
+ if max_retries is None:
14
+ return 0
15
+ if max_retries < 0:
16
+ return 0
17
+ return max_retries
18
+
19
+
20
+ def compute_delay(attempt: int, retry_backoff_s: float) -> float:
21
+ return retry_backoff_s * (2**attempt)
22
+
23
+
24
+ def run_with_retries(
25
+ *,
26
+ func: Callable[[], T],
27
+ max_retries: int,
28
+ retry_backoff_s: float,
29
+ request_id: str | None = None,
30
+ ) -> T:
31
+ attempt = 0
32
+ while True:
33
+ try:
34
+ return func()
35
+ except Exception as exc:
36
+ if attempt >= max_retries:
37
+ raise
38
+ delay = compute_delay(attempt, retry_backoff_s)
39
+ logger.warning(
40
+ "Retrying LLM request: attempt=%d delay=%.2fs request_id=%s error=%s",
41
+ attempt + 1,
42
+ delay,
43
+ request_id,
44
+ exc,
45
+ )
46
+ time.sleep(delay)
47
+ attempt += 1
48
+
49
+
50
+ async def run_with_retries_async(
51
+ *,
52
+ func: Callable[[], Awaitable[T]],
53
+ max_retries: int,
54
+ retry_backoff_s: float,
55
+ request_id: str | None = None,
56
+ ) -> T:
57
+ attempt = 0
58
+ while True:
59
+ try:
60
+ return await func()
61
+ except Exception as exc:
62
+ if attempt >= max_retries:
63
+ raise
64
+ delay = compute_delay(attempt, retry_backoff_s)
65
+ logger.warning(
66
+ "Retrying LLM request (async): attempt=%d delay=%.2fs request_id=%s error=%s",
67
+ attempt + 1,
68
+ delay,
69
+ request_id,
70
+ exc,
71
+ )
72
+ await asyncio.sleep(delay)
73
+ attempt += 1
74
+
75
+
76
+ async def run_sync_in_thread(func: Callable[[], T]) -> T:
77
+ loop = asyncio.get_running_loop()
78
+ return await loop.run_in_executor(None, func)
@@ -1,14 +0,0 @@
1
- ccs_llmconnector-1.0.6.dist-info/licenses/LICENSE,sha256=rPcz2YmBB9VUWZTLJcRO_B4jKDpqmGRYi2eSI-unysg,1083
2
- llmconnector/__init__.py,sha256=RIprtUKqu2SrUmPJ8C7lPpCpvknpJqd93CUyxcaXy1I,1213
3
- llmconnector/anthropic_client.py,sha256=sBcJVmYbqTWeT_twcpDz-00XTreLjZlJ1ifVE4ik5TM,7889
4
- llmconnector/client.py,sha256=t_vWLcL0QS7w1KNwVYc8KEmtmHih5elRMelY3RhApFg,6261
5
- llmconnector/client_cli.py,sha256=cxu2NKix-9axNeY5jbfqR5rKPKJ-oqBSnJCY8PKMhYY,10660
6
- llmconnector/gemini_client.py,sha256=ZdNf4teG0RiV95y3mRMgsjhS-1vrsrPPIEjP9CsKYKE,10893
7
- llmconnector/grok_client.py,sha256=SXcufcsrYDQgx0tK7EOfIBybTZlEdhZc0MV6siUHyyQ,6453
8
- llmconnector/openai_client.py,sha256=TeXfJq1YnQ9gegjpQyOj_7h9VY4tJk6dYvEw4KQIUU8,5993
9
- llmconnector/py.typed,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
10
- ccs_llmconnector-1.0.6.dist-info/METADATA,sha256=msjO02kEy78WrivW8TL6g4ANV8VkFYnVyLFxmMQ9DYk,15041
11
- ccs_llmconnector-1.0.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- ccs_llmconnector-1.0.6.dist-info/entry_points.txt,sha256=eFvLY3nHAG_QhaKlemhhK7echfezW0KiMdSNMZOStLc,60
13
- ccs_llmconnector-1.0.6.dist-info/top_level.txt,sha256=Doer7TAUsN8UXQfPHPNsuBXVNCz2uV-Q0v4t4fwv_MM,13
14
- ccs_llmconnector-1.0.6.dist-info/RECORD,,