ccs-llmconnector 1.0.6__py3-none-any.whl → 1.1.1__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.
@@ -6,13 +6,15 @@ import base64
6
6
  import mimetypes
7
7
  from pathlib import Path
8
8
  import logging
9
- from typing import Optional, Sequence, Union
9
+ from typing import Optional, Sequence
10
10
 
11
11
  from xai_sdk import Client
12
12
  from xai_sdk.chat import image as chat_image
13
13
  from xai_sdk.chat import user
14
14
 
15
- ImageInput = Union[str, Path]
15
+ from .types import ImageInput, MessageSequence, normalize_messages
16
+ from .utils import clamp_retries, run_sync_in_thread, run_with_retries
17
+
16
18
  logger = logging.getLogger(__name__)
17
19
 
18
20
 
@@ -23,11 +25,16 @@ class GrokClient:
23
25
  self,
24
26
  *,
25
27
  api_key: str,
26
- prompt: str,
28
+ prompt: Optional[str] = None,
27
29
  model: str,
28
30
  max_tokens: int = 32000,
29
31
  reasoning_effort: Optional[str] = None,
30
32
  images: Optional[Sequence[ImageInput]] = None,
33
+ messages: Optional[MessageSequence] = None,
34
+ request_id: Optional[str] = None,
35
+ timeout_s: Optional[float] = None,
36
+ max_retries: Optional[int] = None,
37
+ retry_backoff_s: float = 0.5,
31
38
  ) -> str:
32
39
  """Generate a response from the specified Grok model.
33
40
 
@@ -38,6 +45,11 @@ class GrokClient:
38
45
  max_tokens: Cap for tokens in the generated response, defaults to 32000.
39
46
  reasoning_effort: Optional hint for reasoning-focused models (``"low"`` or ``"high"``).
40
47
  images: Optional collection of image references (local paths, URLs, or data URLs).
48
+ messages: Optional list of chat-style messages (role/content).
49
+ request_id: Optional request identifier for tracing/logging.
50
+ timeout_s: Optional request timeout in seconds (best-effort).
51
+ max_retries: Optional retry count for transient failures.
52
+ retry_backoff_s: Base delay (seconds) for exponential backoff between retries.
41
53
 
42
54
  Returns:
43
55
  The text output produced by the model.
@@ -48,14 +60,17 @@ class GrokClient:
48
60
  """
49
61
  if not api_key:
50
62
  raise ValueError("api_key must be provided.")
51
- if not prompt and not images:
52
- raise ValueError("At least one of prompt or images must be provided.")
63
+ if not prompt and not messages and not images:
64
+ raise ValueError("At least one of prompt, messages, or images must be provided.")
53
65
  if not model:
54
66
  raise ValueError("model must be provided.")
55
67
 
56
- message_parts = []
57
- if prompt:
58
- message_parts.append(prompt)
68
+ normalized_messages = normalize_messages(prompt=prompt, messages=messages)
69
+ message_parts: list[object] = []
70
+ if normalized_messages:
71
+ for message in normalized_messages:
72
+ if message["content"]:
73
+ message_parts.append(f"{message['role']}: {message['content']}")
59
74
 
60
75
  if images:
61
76
  for image in images:
@@ -64,53 +79,104 @@ class GrokClient:
64
79
  if not message_parts:
65
80
  raise ValueError("No content provided for response generation.")
66
81
 
67
- grok_client = Client(api_key=api_key)
68
-
69
- create_kwargs = {
70
- "model": model,
71
- "max_tokens": max_tokens,
72
- "messages": [user(*message_parts)],
73
- }
74
-
75
- normalized_effort = (reasoning_effort or "").strip().lower()
76
- if normalized_effort in {"low", "high"}:
77
- create_kwargs["reasoning_effort"] = normalized_effort
78
-
79
- try:
80
- chat = grok_client.chat.create(**create_kwargs)
81
- response = chat.sample()
82
- except Exception as exc:
83
- logger.exception("xAI Grok chat request failed: %s", exc)
84
- raise
85
-
86
- content = getattr(response, "content", None)
87
- if content:
82
+ retry_count = clamp_retries(max_retries)
83
+
84
+ def _run_request() -> str:
85
+ grok_client = Client(api_key=api_key)
86
+
87
+ create_kwargs = {
88
+ "model": model,
89
+ "max_tokens": max_tokens,
90
+ "messages": [user(*message_parts)],
91
+ }
92
+
93
+ normalized_effort = (reasoning_effort or "").strip().lower()
94
+ if normalized_effort in {"low", "high"}:
95
+ create_kwargs["reasoning_effort"] = normalized_effort
96
+
97
+ if timeout_s is not None:
98
+ create_kwargs["timeout"] = timeout_s
99
+
100
+ try:
101
+ chat = grok_client.chat.create(**create_kwargs)
102
+ response = chat.sample()
103
+ except Exception as exc:
104
+ logger.exception(
105
+ "xAI Grok chat request failed: %s request_id=%s",
106
+ exc,
107
+ request_id,
108
+ )
109
+ raise
110
+
111
+ content = getattr(response, "content", None)
112
+ if content:
113
+ logger.info(
114
+ "xAI chat succeeded: model=%s images=%d text_len=%d request_id=%s",
115
+ model,
116
+ len(images or []),
117
+ len(content or ""),
118
+ request_id,
119
+ )
120
+ return content
121
+
122
+ reasoning_content = getattr(response, "reasoning_content", None)
123
+ if reasoning_content:
124
+ logger.info(
125
+ "xAI chat succeeded (reasoning): model=%s images=%d text_len=%d request_id=%s",
126
+ model,
127
+ len(images or []),
128
+ len(reasoning_content or ""),
129
+ request_id,
130
+ )
131
+ return reasoning_content
132
+
133
+ # Treat successful calls without textual content as a successful, empty response
134
+ # rather than raising. This aligns with callers that handle empty outputs gracefully.
88
135
  logger.info(
89
- "xAI chat succeeded: model=%s images=%d text_len=%d",
136
+ "xAI chat succeeded with no text: model=%s images=%d request_id=%s",
90
137
  model,
91
138
  len(images or []),
92
- len(content or ""),
139
+ request_id,
93
140
  )
94
- return content
141
+ return ""
95
142
 
96
- reasoning_content = getattr(response, "reasoning_content", None)
97
- if reasoning_content:
98
- logger.info(
99
- "xAI chat succeeded (reasoning): model=%s images=%d text_len=%d",
100
- model,
101
- len(images or []),
102
- len(reasoning_content or ""),
143
+ return run_with_retries(
144
+ func=_run_request,
145
+ max_retries=retry_count,
146
+ retry_backoff_s=retry_backoff_s,
147
+ request_id=request_id,
148
+ )
149
+
150
+ async def async_generate_response(
151
+ self,
152
+ *,
153
+ api_key: str,
154
+ prompt: Optional[str] = None,
155
+ model: str,
156
+ max_tokens: int = 32000,
157
+ reasoning_effort: Optional[str] = None,
158
+ images: Optional[Sequence[ImageInput]] = None,
159
+ messages: Optional[MessageSequence] = None,
160
+ request_id: Optional[str] = None,
161
+ timeout_s: Optional[float] = None,
162
+ max_retries: Optional[int] = None,
163
+ retry_backoff_s: float = 0.5,
164
+ ) -> str:
165
+ return await run_sync_in_thread(
166
+ lambda: self.generate_response(
167
+ api_key=api_key,
168
+ prompt=prompt,
169
+ model=model,
170
+ max_tokens=max_tokens,
171
+ reasoning_effort=reasoning_effort,
172
+ images=images,
173
+ messages=messages,
174
+ request_id=request_id,
175
+ timeout_s=timeout_s,
176
+ max_retries=max_retries,
177
+ retry_backoff_s=retry_backoff_s,
103
178
  )
104
- return reasoning_content
105
-
106
- # Treat successful calls without textual content as a successful, empty response
107
- # rather than raising. This aligns with callers that handle empty outputs gracefully.
108
- logger.info(
109
- "xAI chat succeeded with no text: model=%s images=%d",
110
- model,
111
- len(images or []),
112
179
  )
113
- return ""
114
180
 
115
181
  def generate_image(
116
182
  self,
@@ -128,42 +194,106 @@ class GrokClient:
128
194
  """
129
195
  raise NotImplementedError("Image generation is not implemented for Grok.")
130
196
 
131
- def list_models(self, *, api_key: str) -> list[dict[str, Optional[str]]]:
197
+ async def async_generate_image(
198
+ self,
199
+ *,
200
+ api_key: str,
201
+ prompt: str,
202
+ model: str,
203
+ image_size: str = "2K",
204
+ image: Optional[ImageInput] = None,
205
+ ) -> bytes:
206
+ return await run_sync_in_thread(
207
+ lambda: self.generate_image(
208
+ api_key=api_key,
209
+ prompt=prompt,
210
+ model=model,
211
+ image_size=image_size,
212
+ image=image,
213
+ )
214
+ )
215
+
216
+ def list_models(
217
+ self,
218
+ *,
219
+ api_key: str,
220
+ request_id: Optional[str] = None,
221
+ timeout_s: Optional[float] = None,
222
+ max_retries: Optional[int] = None,
223
+ retry_backoff_s: float = 0.5,
224
+ ) -> list[dict[str, Optional[str]]]:
132
225
  """Return the Grok language models available to the authenticated account."""
133
226
  if not api_key:
134
227
  raise ValueError("api_key must be provided.")
135
228
 
136
- grok_client = Client(api_key=api_key)
137
- models: list[dict[str, Optional[str]]] = []
138
-
139
- try:
140
- iterator = grok_client.models.list_language_models()
141
- except Exception as exc:
142
- logger.exception("xAI list language models failed: %s", exc)
143
- raise
144
-
145
- for model in iterator:
146
- model_id = getattr(model, "name", None)
147
- if not model_id:
148
- continue
149
-
150
- aliases = getattr(model, "aliases", None)
151
- display_name: Optional[str] = None
152
- if aliases:
153
- try:
154
- display_name = next(iter(aliases)) or None
155
- except (StopIteration, TypeError):
156
- display_name = None
157
-
158
- models.append(
159
- {
160
- "id": model_id,
161
- "display_name": display_name,
162
- }
229
+ retry_count = clamp_retries(max_retries)
230
+
231
+ def _run_request() -> list[dict[str, Optional[str]]]:
232
+ grok_client = Client(api_key=api_key)
233
+ models: list[dict[str, Optional[str]]] = []
234
+
235
+ try:
236
+ iterator = grok_client.models.list_language_models()
237
+ except Exception as exc:
238
+ logger.exception(
239
+ "xAI list language models failed: %s request_id=%s",
240
+ exc,
241
+ request_id,
242
+ )
243
+ raise
244
+
245
+ for model in iterator:
246
+ model_id = getattr(model, "name", None)
247
+ if not model_id:
248
+ continue
249
+
250
+ aliases = getattr(model, "aliases", None)
251
+ display_name: Optional[str] = None
252
+ if aliases:
253
+ try:
254
+ display_name = next(iter(aliases)) or None
255
+ except (StopIteration, TypeError):
256
+ display_name = None
257
+
258
+ models.append(
259
+ {
260
+ "id": model_id,
261
+ "display_name": display_name,
262
+ }
263
+ )
264
+
265
+ logger.info(
266
+ "xAI list_language_models succeeded: count=%d request_id=%s",
267
+ len(models),
268
+ request_id,
163
269
  )
270
+ return models
271
+
272
+ return run_with_retries(
273
+ func=_run_request,
274
+ max_retries=retry_count,
275
+ retry_backoff_s=retry_backoff_s,
276
+ request_id=request_id,
277
+ )
164
278
 
165
- logger.info("xAI list_language_models succeeded: count=%d", len(models))
166
- return models
279
+ async def async_list_models(
280
+ self,
281
+ *,
282
+ api_key: str,
283
+ request_id: Optional[str] = None,
284
+ timeout_s: Optional[float] = None,
285
+ max_retries: Optional[int] = None,
286
+ retry_backoff_s: float = 0.5,
287
+ ) -> list[dict[str, Optional[str]]]:
288
+ return await run_sync_in_thread(
289
+ lambda: self.list_models(
290
+ api_key=api_key,
291
+ request_id=request_id,
292
+ timeout_s=timeout_s,
293
+ max_retries=max_retries,
294
+ retry_backoff_s=retry_backoff_s,
295
+ )
296
+ )
167
297
 
168
298
  @staticmethod
169
299
  def _to_image_url(image: ImageInput) -> str:
@@ -6,16 +6,13 @@ import base64
6
6
  import mimetypes
7
7
  from pathlib import Path
8
8
  import logging
9
- from typing import Optional, Sequence, Union
9
+ from typing import Optional, Sequence
10
10
 
11
11
  from openai import OpenAI
12
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
13
+ from .types import ImageInput, MessageSequence, normalize_messages
14
+ from .utils import clamp_retries, run_sync_in_thread, run_with_retries
17
15
 
18
- ImageInput = Union[str, Path]
19
16
  logger = logging.getLogger(__name__)
20
17
 
21
18
 
@@ -26,11 +23,16 @@ class OpenAIResponsesClient:
26
23
  self,
27
24
  *,
28
25
  api_key: str,
29
- prompt: str,
26
+ prompt: Optional[str] = None,
30
27
  model: str,
31
28
  max_tokens: int = 32000,
32
29
  reasoning_effort: Optional[str] = None,
33
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,
34
36
  ) -> str:
35
37
  """Generate a response from the specified model.
36
38
 
@@ -41,6 +43,11 @@ class OpenAIResponsesClient:
41
43
  max_tokens: Cap for tokens across the entire exchange, defaults to 32000.
42
44
  reasoning_effort: Optional reasoning effort hint (``"low"``, ``"medium"``, or ``"high"``).
43
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.
44
51
 
45
52
  Returns:
46
53
  The text output produced by the model.
@@ -51,52 +58,110 @@ class OpenAIResponsesClient:
51
58
  """
52
59
  if not api_key:
53
60
  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.")
61
+ if not prompt and not messages and not images:
62
+ raise ValueError("At least one of prompt, messages, or images must be provided.")
56
63
  if not model:
57
64
  raise ValueError("model must be provided.")
58
65
 
59
- client = OpenAI(api_key=api_key)
60
-
61
- content_blocks = []
62
- if prompt:
63
- content_blocks.append({"type": "input_text", "text": prompt})
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})
64
73
 
65
74
  if images:
66
- for image in images:
67
- content_blocks.append(self._to_image_block(image))
68
-
69
- if not content_blocks:
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):
70
90
  raise ValueError("No content provided for response generation.")
71
91
 
72
92
  request_payload = {
73
93
  "model": model,
74
- "input": [
75
- {
76
- "role": "user",
77
- "content": content_blocks,
78
- }
79
- ],
94
+ "input": content_messages,
80
95
  "max_output_tokens": max_tokens,
81
96
  }
82
97
 
83
98
  if reasoning_effort:
84
99
  request_payload["reasoning"] = {"effort": reasoning_effort}
85
100
 
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 ""),
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
+ )
98
164
  )
99
- return output_text
100
165
 
101
166
  @staticmethod
102
167
  def _to_image_block(image: ImageInput) -> dict:
@@ -135,35 +200,102 @@ class OpenAIResponsesClient:
135
200
  """
136
201
  raise NotImplementedError("Image generation is not implemented for OpenAI.")
137
202
 
138
- def list_models(self, *, api_key: str) -> list[dict[str, Optional[str]]]:
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]]]:
139
233
  """Return the models available to the authenticated OpenAI account."""
140
234
  if not api_key:
141
235
  raise ValueError("api_key must be provided.")
142
236
 
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
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: