acty-openai 0.1.0__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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ __pycache__/
7
+ .coverage
8
+ .mypy_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Konstantin Polev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: acty-openai
3
+ Version: 0.1.0
4
+ Summary: OpenAI executor for acty
5
+ Project-URL: Homepage, https://github.com/conspol/acty-openai
6
+ Project-URL: Repository, https://github.com/conspol/acty-openai
7
+ Project-URL: Issues, https://github.com/conspol/acty-openai/issues
8
+ Maintainer-email: Konstantin Polev <70580603+conspol@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: langchain,openai,telemetry,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: acty-core<0.2.0,>=0.1.0
21
+ Requires-Dist: acty<0.2.0,>=0.1.0
22
+ Requires-Dist: langchain-openai>=0.2.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2.2; extra == 'dev'
25
+ Requires-Dist: opentelemetry-sdk>=1.39.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
27
+ Requires-Dist: pytest>=9.0.0; extra == 'dev'
28
+ Requires-Dist: twine>=5.1.1; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # acty-openai
32
+
33
+ `acty-openai` provides an `OpenAIExecutor` implementation for Acty. It adapts
34
+ `langchain-openai` chat models to the Acty executor interface and emits the
35
+ shared telemetry attributes used across the wider Acty stack.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install acty-openai
41
+ ```
42
+
43
+ For local development:
44
+
45
+ ```bash
46
+ pip install -e .[dev]
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ import asyncio
53
+
54
+ from acty import ActyEngine, EngineConfig
55
+ from acty_openai import OpenAIExecutor
56
+ from langchain_openai import ChatOpenAI
57
+
58
+
59
+ async def main() -> None:
60
+ model = ChatOpenAI(model="gpt-4o-mini")
61
+ engine = ActyEngine(
62
+ executor=OpenAIExecutor(model=model),
63
+ config=EngineConfig(primer_workers=1, follower_workers=1),
64
+ )
65
+ try:
66
+ payload = {
67
+ "messages": [{"role": "user", "content": "hello"}],
68
+ }
69
+ submission = await engine.submit_group("demo", payload, [])
70
+ if submission.primer is not None:
71
+ result = await submission.primer
72
+ print(result.output)
73
+ finally:
74
+ await engine.close()
75
+
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ ## Notes
81
+
82
+ - if you do not pass a model explicitly, `OpenAIExecutor` requires `langchain-openai`
83
+ - the executor can attach OpenTelemetry span attributes when telemetry is enabled
84
+ - this package depends directly on both `acty` and `acty-core` because it imports both at runtime
85
+
86
+ ## Development
87
+
88
+ - tests live under `tests/`
89
+ - the repo includes unit tests plus an Acty engine integration test for shared telemetry behavior
@@ -0,0 +1,59 @@
1
+ # acty-openai
2
+
3
+ `acty-openai` provides an `OpenAIExecutor` implementation for Acty. It adapts
4
+ `langchain-openai` chat models to the Acty executor interface and emits the
5
+ shared telemetry attributes used across the wider Acty stack.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install acty-openai
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```bash
16
+ pip install -e .[dev]
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from acty import ActyEngine, EngineConfig
25
+ from acty_openai import OpenAIExecutor
26
+ from langchain_openai import ChatOpenAI
27
+
28
+
29
+ async def main() -> None:
30
+ model = ChatOpenAI(model="gpt-4o-mini")
31
+ engine = ActyEngine(
32
+ executor=OpenAIExecutor(model=model),
33
+ config=EngineConfig(primer_workers=1, follower_workers=1),
34
+ )
35
+ try:
36
+ payload = {
37
+ "messages": [{"role": "user", "content": "hello"}],
38
+ }
39
+ submission = await engine.submit_group("demo", payload, [])
40
+ if submission.primer is not None:
41
+ result = await submission.primer
42
+ print(result.output)
43
+ finally:
44
+ await engine.close()
45
+
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ ## Notes
51
+
52
+ - if you do not pass a model explicitly, `OpenAIExecutor` requires `langchain-openai`
53
+ - the executor can attach OpenTelemetry span attributes when telemetry is enabled
54
+ - this package depends directly on both `acty` and `acty-core` because it imports both at runtime
55
+
56
+ ## Development
57
+
58
+ - tests live under `tests/`
59
+ - the repo includes unit tests plus an Acty engine integration test for shared telemetry behavior
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "acty-openai"
3
+ version = "0.1.0"
4
+ description = "OpenAI executor for acty"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ maintainers = [{ name = "Konstantin Polev", email = "70580603+conspol@users.noreply.github.com" }]
10
+ keywords = ["openai", "langchain", "telemetry", "workflow"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = [
21
+ "acty>=0.1.0,<0.2.0",
22
+ "acty-core>=0.1.0,<0.2.0",
23
+ "langchain-openai>=0.2.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "build>=1.2.2",
29
+ "opentelemetry-sdk>=1.39.0",
30
+ "pytest>=9.0.0",
31
+ "pytest-asyncio>=1.3.0",
32
+ "twine>=5.1.1",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/conspol/acty-openai"
37
+ Repository = "https://github.com/conspol/acty-openai"
38
+ Issues = "https://github.com/conspol/acty-openai/issues"
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/acty_openai"]
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ pythonpath = src
4
+ asyncio_mode = auto
@@ -0,0 +1,15 @@
1
+ """OpenAI executor for acty."""
2
+
3
+ from .executor import (
4
+ OpenAIErrorCategory,
5
+ OpenAIExecutor,
6
+ classify_openai_error,
7
+ make_openai_executor,
8
+ )
9
+
10
+ __all__ = [
11
+ "OpenAIErrorCategory",
12
+ "OpenAIExecutor",
13
+ "classify_openai_error",
14
+ "make_openai_executor",
15
+ ]
@@ -0,0 +1,506 @@
1
+ """OpenAI executor for acty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ from enum import Enum
9
+ from typing import Any, Awaitable, Callable, Mapping, Sequence
10
+
11
+ from acty_core.core.types import Job, JobResult
12
+ from acty_core.lifecycle import GroupLifecycleController
13
+ from acty_core.telemetry import TelemetryPrivacyConfig
14
+ from acty_core.telemetry import llm_messages as llm_message_utils
15
+ from acty_core.telemetry.acty_span import build_acty_span_attributes
16
+ from acty_core.telemetry.llm_messages import (
17
+ extract_input_messages as shared_extract_input_messages,
18
+ flatten_batched_messages as shared_flatten_batched_messages,
19
+ flatten_messages as shared_flatten_messages,
20
+ )
21
+ from acty_core.telemetry.llm_tokens import cached_token_attributes as shared_cached_token_attributes
22
+
23
+ try: # Optional OpenTelemetry dependency.
24
+ from opentelemetry import trace as otel_trace
25
+ except Exception: # pragma: no cover - optional dependency
26
+ otel_trace = None
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _is_openrouter_base_url(value: Any) -> bool:
32
+ if value is None:
33
+ return False
34
+ try:
35
+ text = str(value).lower()
36
+ except Exception:
37
+ return False
38
+ return "openrouter.ai" in text
39
+
40
+
41
+ def _extract_base_url_from_model(model: Any) -> str | None:
42
+ for attr in ("openai_api_base", "openai_base_url", "base_url", "api_base"):
43
+ value = getattr(model, attr, None)
44
+ if value:
45
+ return str(value)
46
+ for client_attr in ("client", "async_client"):
47
+ client = getattr(model, client_attr, None)
48
+ if client is None:
49
+ continue
50
+ value = getattr(client, "base_url", None)
51
+ if value:
52
+ return str(value)
53
+ return None
54
+
55
+
56
+ def _infer_llm_system(
57
+ model: Any,
58
+ invoke_kwargs: Mapping[str, Any],
59
+ model_kwargs: Mapping[str, Any] | None,
60
+ ) -> str | None:
61
+ base_url = None
62
+ if isinstance(model_kwargs, Mapping):
63
+ base_url = model_kwargs.get("base_url") or model_kwargs.get("openai_api_base")
64
+ if base_url is None and isinstance(invoke_kwargs, Mapping):
65
+ base_url = invoke_kwargs.get("base_url") or invoke_kwargs.get("openai_api_base")
66
+ if base_url is None:
67
+ base_url = _extract_base_url_from_model(model)
68
+ if _is_openrouter_base_url(base_url):
69
+ return "openrouter"
70
+ return None
71
+
72
+
73
+ def _apply_gateway_attributes(
74
+ span: Any,
75
+ *,
76
+ system: str | None,
77
+ ) -> None:
78
+ if not system:
79
+ return
80
+ span.set_attribute("gen_ai.system", system)
81
+ span.set_attribute("llm.system", system)
82
+ span.set_attribute("llm.provider", system)
83
+
84
+
85
+ class OpenAIErrorCategory(Enum):
86
+ TRANSIENT = "transient"
87
+ FATAL = "fatal"
88
+
89
+
90
+ OpenAIErrorClassifier = Callable[[BaseException], OpenAIErrorCategory]
91
+
92
+
93
+ def classify_openai_error(exc: BaseException) -> OpenAIErrorCategory:
94
+ status_code = _extract_status_code(exc)
95
+ if status_code in {401, 403}:
96
+ return OpenAIErrorCategory.FATAL
97
+ if status_code in {408, 409, 425, 429, 500, 502, 503, 504}:
98
+ return OpenAIErrorCategory.TRANSIENT
99
+
100
+ try:
101
+ import openai # type: ignore
102
+ except Exception: # pragma: no cover - optional dependency
103
+ openai = None
104
+ if openai is not None:
105
+ for name in ("AuthenticationError", "PermissionDeniedError"):
106
+ cls = getattr(openai, name, None)
107
+ if cls is not None and isinstance(exc, cls):
108
+ return OpenAIErrorCategory.FATAL
109
+ for name in (
110
+ "RateLimitError",
111
+ "APITimeoutError",
112
+ "APIConnectionError",
113
+ "InternalServerError",
114
+ "ServiceUnavailableError",
115
+ ):
116
+ cls = getattr(openai, name, None)
117
+ if cls is not None and isinstance(exc, cls):
118
+ return OpenAIErrorCategory.TRANSIENT
119
+
120
+ try:
121
+ import httpx # type: ignore
122
+ except Exception:
123
+ httpx = None
124
+ if httpx is not None and isinstance(exc, httpx.RequestError):
125
+ return OpenAIErrorCategory.TRANSIENT
126
+ try:
127
+ import httpcore # type: ignore
128
+ except Exception:
129
+ httpcore = None
130
+ if httpcore is not None and isinstance(
131
+ exc,
132
+ (
133
+ httpcore.NetworkError,
134
+ httpcore.TimeoutException,
135
+ httpcore.ProxyError,
136
+ ),
137
+ ):
138
+ return OpenAIErrorCategory.TRANSIENT
139
+
140
+ if isinstance(exc, (TimeoutError, ConnectionError, OSError, asyncio.TimeoutError)):
141
+ return OpenAIErrorCategory.TRANSIENT
142
+
143
+ message = str(exc).lower()
144
+ if any(
145
+ keyword in message
146
+ for keyword in (
147
+ "timeout",
148
+ "timed out",
149
+ "rate limit",
150
+ "too many requests",
151
+ "temporarily",
152
+ "unavailable",
153
+ "connection",
154
+ "overloaded",
155
+ "try again",
156
+ "authentication",
157
+ "unauthorized",
158
+ "api key",
159
+ "permission denied",
160
+ )
161
+ ):
162
+ if any(term in message for term in ("unauthorized", "api key", "permission", "authentication")):
163
+ return OpenAIErrorCategory.FATAL
164
+ return OpenAIErrorCategory.TRANSIENT
165
+
166
+ return OpenAIErrorCategory.TRANSIENT
167
+
168
+
169
+ class OpenAIExecutor:
170
+ """Execute OpenAI-compatible LangChain calls from acty job payloads."""
171
+
172
+ handles_lifecycle = False
173
+ supports_executor_retry = False
174
+
175
+ def __init__(
176
+ self,
177
+ controller: GroupLifecycleController | None = None,
178
+ *,
179
+ model: Any | None = None,
180
+ model_factory: Callable[[], Any] | None = None,
181
+ model_kwargs: Mapping[str, Any] | None = None,
182
+ error_classifier: OpenAIErrorClassifier | None = None,
183
+ span_enrichment: bool = True,
184
+ max_messages: int = 20,
185
+ max_message_chars: int = 4096,
186
+ emit_batched_messages: bool = False,
187
+ telemetry_privacy: TelemetryPrivacyConfig | None = None,
188
+ ) -> None:
189
+ if model is not None and model_factory is not None:
190
+ raise ValueError("Provide either model or model_factory, not both")
191
+ self._controller = controller
192
+ self._model = model
193
+ self._model_factory = model_factory
194
+ self._model_kwargs = dict(model_kwargs) if model_kwargs else None
195
+ self._error_classifier = error_classifier
196
+ self._span_enrichment = bool(span_enrichment)
197
+ self._max_messages = max(0, int(max_messages))
198
+ self._max_message_chars = max(0, int(max_message_chars))
199
+ self._emit_batched_messages = bool(emit_batched_messages)
200
+ self._telemetry_privacy = telemetry_privacy or TelemetryPrivacyConfig()
201
+
202
+ def bind(self, controller: GroupLifecycleController) -> None:
203
+ self._controller = controller
204
+
205
+ def bind_telemetry_privacy(self, telemetry_privacy: TelemetryPrivacyConfig) -> None:
206
+ self._telemetry_privacy = telemetry_privacy
207
+
208
+ async def execute(self, job: Job, *, pool: str) -> JobResult: # noqa: ARG002 - pool kept for interface
209
+ call_input, invoke_kwargs = _resolve_call(job.payload)
210
+ model = self._get_model()
211
+ span = None
212
+ if self._span_enrichment:
213
+ span = _get_recording_span(log_missing=True)
214
+ if span is not None:
215
+ _apply_acty_span_attributes(span, job, invoke_kwargs)
216
+ system = _infer_llm_system(model, invoke_kwargs, self._model_kwargs)
217
+ _apply_gateway_attributes(span, system=system)
218
+ _apply_input_message_attributes(
219
+ span,
220
+ job.payload,
221
+ call_input,
222
+ max_messages=self._max_messages,
223
+ max_message_chars=self._max_message_chars,
224
+ emit_batched_messages=self._emit_batched_messages,
225
+ telemetry_privacy=self._telemetry_privacy,
226
+ )
227
+ try:
228
+ result = await _invoke_model(model, call_input, invoke_kwargs)
229
+ except Exception as exc:
230
+ category = self._classify_error(exc)
231
+ _attach_error_category(exc, category)
232
+ raise
233
+ if self._span_enrichment:
234
+ span_after = _get_recording_span(log_missing=False)
235
+ target_span = span_after or span
236
+ if target_span is not None:
237
+ if span is None and span_after is not None:
238
+ _apply_acty_span_attributes(target_span, job, invoke_kwargs)
239
+ system = _infer_llm_system(model, invoke_kwargs, self._model_kwargs)
240
+ _apply_gateway_attributes(target_span, system=system)
241
+ _apply_input_message_attributes(
242
+ target_span,
243
+ job.payload,
244
+ call_input,
245
+ max_messages=self._max_messages,
246
+ max_message_chars=self._max_message_chars,
247
+ emit_batched_messages=self._emit_batched_messages,
248
+ telemetry_privacy=self._telemetry_privacy,
249
+ )
250
+ else:
251
+ system = _infer_llm_system(model, invoke_kwargs, self._model_kwargs)
252
+ _apply_gateway_attributes(target_span, system=system)
253
+ _apply_cached_token_attributes(target_span, result)
254
+ return JobResult(
255
+ job_id=job.id,
256
+ kind=job.kind,
257
+ ok=True,
258
+ output={"result": result},
259
+ group_id=job.group_id,
260
+ follower_id=job.follower_id,
261
+ )
262
+
263
+ def _get_model(self) -> Any:
264
+ if self._model is not None:
265
+ return self._model
266
+ if self._model_factory is not None:
267
+ self._model = self._model_factory()
268
+ return self._model
269
+ try:
270
+ from langchain_openai import ChatOpenAI
271
+ except ImportError as exc: # pragma: no cover - optional dependency
272
+ raise ImportError("langchain-openai is required when model is not provided") from exc
273
+ kwargs = dict(self._model_kwargs) if self._model_kwargs else {}
274
+ self._model = ChatOpenAI(**kwargs)
275
+ return self._model
276
+
277
+ def _classify_error(self, exc: BaseException) -> OpenAIErrorCategory:
278
+ classifier = self._error_classifier or classify_openai_error
279
+ try:
280
+ return classifier(exc)
281
+ except Exception:
282
+ logger.debug("openai_error_classification_failed", exc_info=True)
283
+ return OpenAIErrorCategory.TRANSIENT
284
+
285
+
286
+ def make_openai_executor(
287
+ *,
288
+ model: Any | None = None,
289
+ model_factory: Callable[[], Any] | None = None,
290
+ model_kwargs: Mapping[str, Any] | None = None,
291
+ error_classifier: OpenAIErrorClassifier | None = None,
292
+ span_enrichment: bool = True,
293
+ max_messages: int = 20,
294
+ max_message_chars: int = 4096,
295
+ emit_batched_messages: bool = False,
296
+ telemetry_privacy: TelemetryPrivacyConfig | None = None,
297
+ ) -> OpenAIExecutor:
298
+ """Factory helper for a configured OpenAI executor."""
299
+ return OpenAIExecutor(
300
+ model=model,
301
+ model_factory=model_factory,
302
+ model_kwargs=model_kwargs,
303
+ error_classifier=error_classifier,
304
+ span_enrichment=span_enrichment,
305
+ max_messages=max_messages,
306
+ max_message_chars=max_message_chars,
307
+ emit_batched_messages=emit_batched_messages,
308
+ telemetry_privacy=telemetry_privacy,
309
+ )
310
+
311
+
312
+ def _resolve_call(payload: Any) -> tuple[Any, Mapping[str, Any]]:
313
+ if not isinstance(payload, Mapping):
314
+ return payload, {}
315
+
316
+ invoke_kwargs = payload.get("invoke_kwargs")
317
+ candidate: Any = payload
318
+ if "payload" in payload and not any(key in payload for key in ("messages", "input", "prompt")):
319
+ inner = payload.get("payload")
320
+ if isinstance(inner, Mapping):
321
+ candidate = inner
322
+ else:
323
+ if isinstance(invoke_kwargs, Mapping):
324
+ return inner, invoke_kwargs
325
+ return inner, {}
326
+
327
+ if isinstance(candidate, Mapping):
328
+ if "messages" in candidate:
329
+ call_input = candidate["messages"]
330
+ elif "input" in candidate:
331
+ call_input = candidate["input"]
332
+ elif "prompt" in candidate:
333
+ call_input = candidate["prompt"]
334
+ else:
335
+ call_input = candidate
336
+ if not isinstance(invoke_kwargs, Mapping):
337
+ invoke_kwargs = candidate.get("invoke_kwargs")
338
+ else:
339
+ call_input = candidate
340
+
341
+ if isinstance(invoke_kwargs, Mapping):
342
+ return call_input, invoke_kwargs
343
+ return call_input, {}
344
+
345
+
346
+ async def _invoke_model(model: Any, call_input: Any, invoke_kwargs: Mapping[str, Any]) -> Any:
347
+ if hasattr(model, "ainvoke"):
348
+ result = model.ainvoke(call_input, **invoke_kwargs)
349
+ if inspect.isawaitable(result):
350
+ return await result
351
+ return result
352
+ if hasattr(model, "invoke"):
353
+ result = model.invoke(call_input, **invoke_kwargs)
354
+ if inspect.isawaitable(result):
355
+ return await result
356
+ return result
357
+ raise TypeError("Model must provide invoke or ainvoke")
358
+
359
+
360
+ def _extract_status_code(exc: BaseException) -> int | None:
361
+ for attr in ("status_code", "status", "http_status", "code"):
362
+ value = getattr(exc, attr, None)
363
+ if isinstance(value, int):
364
+ return value
365
+ response = getattr(exc, "response", None)
366
+ if response is not None:
367
+ for attr in ("status_code", "status"):
368
+ value = getattr(response, attr, None)
369
+ if isinstance(value, int):
370
+ return value
371
+ return None
372
+
373
+
374
+ def _get_recording_span(*, log_missing: bool) -> Any | None:
375
+ if otel_trace is None:
376
+ if log_missing:
377
+ logger.warning("LLM span enrichment skipped: OpenTelemetry unavailable.")
378
+ return None
379
+ span = otel_trace.get_current_span()
380
+ if span is None or not span.is_recording():
381
+ if log_missing:
382
+ logger.warning("LLM span enrichment skipped: current span not recording.")
383
+ return None
384
+ return span
385
+
386
+
387
+ def _apply_acty_span_attributes(
388
+ span: Any,
389
+ job: Job,
390
+ invoke_kwargs: Mapping[str, Any],
391
+ ) -> None:
392
+ attributes = build_acty_span_attributes(job, invoke_kwargs)
393
+ _set_span_attributes(span, attributes)
394
+
395
+
396
+ def _apply_input_message_attributes(
397
+ span: Any,
398
+ payload: Any,
399
+ call_input: Any,
400
+ *,
401
+ max_messages: int,
402
+ max_message_chars: int,
403
+ emit_batched_messages: bool = False,
404
+ telemetry_privacy: TelemetryPrivacyConfig | None = None,
405
+ ) -> None:
406
+ if not _should_emit_input_messages(telemetry_privacy):
407
+ return
408
+ messages, batch_selected = _extract_input_messages(
409
+ payload,
410
+ call_input,
411
+ emit_batched_messages=emit_batched_messages,
412
+ )
413
+ if messages is None:
414
+ return
415
+ if _looks_like_message_batch(messages):
416
+ attributes = _flatten_batched_messages(
417
+ messages,
418
+ max_messages=max_messages,
419
+ max_message_chars=max_message_chars,
420
+ )
421
+ else:
422
+ attributes = _flatten_messages(
423
+ messages,
424
+ max_messages=max_messages,
425
+ max_message_chars=max_message_chars,
426
+ )
427
+ if batch_selected is not None:
428
+ attributes["acty.message_batch_selected"] = batch_selected
429
+ _set_span_attributes(span, attributes)
430
+
431
+
432
+ def _should_emit_input_messages(telemetry_privacy: TelemetryPrivacyConfig | None = None) -> bool:
433
+ policy = telemetry_privacy or TelemetryPrivacyConfig()
434
+ return policy.should_emit_input_messages()
435
+
436
+
437
+ def _extract_input_messages(
438
+ payload: Any,
439
+ call_input: Any,
440
+ *,
441
+ emit_batched_messages: bool,
442
+ ) -> tuple[Sequence[Any] | Sequence[Sequence[Any]] | None, int | None]:
443
+ return shared_extract_input_messages(
444
+ payload,
445
+ call_input,
446
+ emit_batched_messages=emit_batched_messages,
447
+ )
448
+
449
+
450
+ def _looks_like_message_batch(value: Any) -> bool:
451
+ return llm_message_utils._looks_like_message_batch(value)
452
+
453
+
454
+ def _flatten_messages(
455
+ messages: Sequence[Any],
456
+ *,
457
+ max_messages: int,
458
+ max_message_chars: int,
459
+ ) -> dict[str, Any]:
460
+ return shared_flatten_messages(
461
+ messages,
462
+ max_messages=max_messages,
463
+ max_message_chars=max_message_chars,
464
+ )
465
+
466
+
467
+ def _flatten_batched_messages(
468
+ batches: Sequence[Sequence[Any]],
469
+ *,
470
+ max_messages: int,
471
+ max_message_chars: int,
472
+ ) -> dict[str, Any]:
473
+ return shared_flatten_batched_messages(
474
+ batches,
475
+ max_messages=max_messages,
476
+ max_message_chars=max_message_chars,
477
+ )
478
+
479
+
480
+ def _apply_cached_token_attributes(span: Any, output: Any) -> None:
481
+ attributes = shared_cached_token_attributes(output)
482
+ if not attributes:
483
+ return
484
+ _set_span_attributes(span, attributes)
485
+
486
+
487
+ def _set_span_attributes(span: Any, attributes: Mapping[str, Any]) -> None:
488
+ for key, value in attributes.items():
489
+ if value is None:
490
+ continue
491
+ span.set_attribute(key, value)
492
+
493
+
494
+ def _attach_error_category(exc: BaseException, category: OpenAIErrorCategory) -> None:
495
+ try:
496
+ setattr(exc, "acty_error_category", category.value)
497
+ except Exception:
498
+ return
499
+
500
+
501
+ __all__ = [
502
+ "OpenAIErrorCategory",
503
+ "OpenAIExecutor",
504
+ "classify_openai_error",
505
+ "make_openai_executor",
506
+ ]
@@ -0,0 +1,19 @@
1
+ from acty_openai.executor import OpenAIErrorCategory, classify_openai_error
2
+
3
+
4
+ class DummyError(Exception):
5
+ def __init__(self, status_code: int | None = None, message: str = "") -> None:
6
+ super().__init__(message)
7
+ self.status_code = status_code
8
+
9
+
10
+ def test_classify_openai_error_status_codes() -> None:
11
+ assert classify_openai_error(DummyError(status_code=401)) is OpenAIErrorCategory.FATAL
12
+ assert classify_openai_error(DummyError(status_code=403)) is OpenAIErrorCategory.FATAL
13
+ assert classify_openai_error(DummyError(status_code=429)) is OpenAIErrorCategory.TRANSIENT
14
+ assert classify_openai_error(DummyError(status_code=503)) is OpenAIErrorCategory.TRANSIENT
15
+
16
+
17
+ def test_classify_openai_error_message_fallbacks() -> None:
18
+ assert classify_openai_error(DummyError(message="rate limit exceeded")) is OpenAIErrorCategory.TRANSIENT
19
+ assert classify_openai_error(DummyError(message="authentication failed")) is OpenAIErrorCategory.FATAL
@@ -0,0 +1,96 @@
1
+ from dataclasses import dataclass
2
+
3
+ import pytest
4
+
5
+ from acty_core.core.types import GroupId, Job, JobId
6
+ from acty_openai import OpenAIExecutor
7
+ import acty_openai.executor as executor_module
8
+
9
+
10
+ class DummyModel:
11
+ def __init__(self) -> None:
12
+ self.calls = []
13
+
14
+ async def ainvoke(self, call_input, **kwargs):
15
+ self.calls.append((call_input, kwargs))
16
+ return "ok"
17
+
18
+
19
+ @dataclass
20
+ class DummyMessage:
21
+ role: str
22
+ content: str
23
+ name: str | None = None
24
+ additional_kwargs: dict | None = None
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_executor_invokes_model_with_input() -> None:
29
+ model = DummyModel()
30
+ executor = OpenAIExecutor(model=model, span_enrichment=False)
31
+ job = Job(
32
+ id=JobId("job-1"),
33
+ kind="task",
34
+ payload={"input": "hi", "invoke_kwargs": {"temperature": 0}},
35
+ group_id=GroupId("g1"),
36
+ )
37
+
38
+ result = await executor.execute(job, pool="p1")
39
+ assert result.ok is True
40
+ assert result.output == {"result": "ok"}
41
+ assert model.calls == [("hi", {"temperature": 0})]
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_executor_unwraps_acty_payload_wrapper() -> None:
46
+ model = DummyModel()
47
+ executor = OpenAIExecutor(model=model, span_enrichment=False)
48
+ job = Job(
49
+ id=JobId("job-2"),
50
+ kind="task",
51
+ payload={"payload": {"messages": [{"role": "user", "content": "hi"}]}},
52
+ group_id=GroupId("g2"),
53
+ )
54
+
55
+ result = await executor.execute(job, pool="p1")
56
+ assert result.ok is True
57
+ assert model.calls == [([{"role": "user", "content": "hi"}], {})]
58
+
59
+
60
+ def test_extract_input_messages_batch_selection() -> None:
61
+ payload = {
62
+ "messages": [
63
+ [{"role": "user", "content": "hi"}],
64
+ [{"role": "user", "content": "bye"}],
65
+ ]
66
+ }
67
+ messages, batch_selected = executor_module._extract_input_messages(
68
+ payload,
69
+ payload["messages"],
70
+ emit_batched_messages=False,
71
+ )
72
+ assert messages == [{"role": "user", "content": "hi"}]
73
+ assert batch_selected == 0
74
+
75
+ messages, batch_selected = executor_module._extract_input_messages(
76
+ payload,
77
+ payload["messages"],
78
+ emit_batched_messages=True,
79
+ )
80
+ assert messages == payload["messages"]
81
+ assert batch_selected is None
82
+
83
+
84
+ def test_flatten_messages_handles_mapping_and_object() -> None:
85
+ messages = [
86
+ {"role": "user", "content": "hi", "additional_kwargs": {"tool_call_id": "tool-1"}},
87
+ DummyMessage(role="assistant", content="ok", name="helper"),
88
+ ]
89
+ attrs = executor_module._flatten_messages(messages, max_messages=10, max_message_chars=100)
90
+
91
+ assert attrs["llm.input_messages.0.message.role"] == "user"
92
+ assert attrs["llm.input_messages.0.message.content"] == "hi"
93
+ assert attrs["llm.input_messages.0.message.tool_call_id"] == "tool-1"
94
+ assert attrs["llm.input_messages.1.message.role"] == "assistant"
95
+ assert attrs["llm.input_messages.1.message.content"] == "ok"
96
+ assert attrs["llm.input_messages.1.message.name"] == "helper"
@@ -0,0 +1,70 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from opentelemetry import trace as otel_trace
6
+ from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
7
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
8
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
9
+
10
+ from acty import ActyEngine, EngineConfig
11
+ from acty_openai import OpenAIExecutor
12
+
13
+
14
+ class TelemetryModel:
15
+ def __init__(self, cache_read: int) -> None:
16
+ self._cache_read = cache_read
17
+
18
+ def invoke(self, call_input, **kwargs):
19
+ _ = call_input
20
+ _ = kwargs
21
+ return DummyOutput(self._cache_read)
22
+
23
+
24
+ class DummyOutput:
25
+ def __init__(self, cache_read: int) -> None:
26
+ self.llm_output = {"token_usage": {"precached_prompt_tokens": cache_read}}
27
+
28
+
29
+ def _install_exporter() -> tuple[InMemorySpanExporter, SDKTracerProvider]:
30
+ exporter = InMemorySpanExporter()
31
+ provider = otel_trace.get_tracer_provider()
32
+ if not isinstance(provider, SDKTracerProvider):
33
+ provider = SDKTracerProvider()
34
+ otel_trace.set_tracer_provider(provider)
35
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
36
+ return exporter, provider
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_openai_engine_emits_shared_telemetry_attributes() -> None:
41
+ exporter, provider = _install_exporter()
42
+ model = TelemetryModel(cache_read=7)
43
+ executor = OpenAIExecutor(model=model)
44
+ engine = ActyEngine(
45
+ executor=executor,
46
+ config=EngineConfig(primer_workers=1, follower_workers=1),
47
+ )
48
+ try:
49
+ payload = {
50
+ "messages": [{"role": "user", "content": "hello"}],
51
+ "invoke_kwargs": {"config": {"run_name": "telemetry-run"}},
52
+ }
53
+ submission = await engine.submit_group("g-telemetry", payload, [])
54
+ assert submission.primer is not None
55
+ result = await asyncio.wait_for(submission.primer, timeout=2.0)
56
+ assert result.ok is True
57
+ finally:
58
+ await engine.close()
59
+
60
+ provider.force_flush()
61
+ spans = exporter.get_finished_spans()
62
+ assert spans
63
+ job_id = str(result.job_id)
64
+ job_spans = [span for span in spans if span.attributes.get("acty.job_id") == job_id]
65
+ assert job_spans
66
+ attrs = job_spans[0].attributes
67
+ assert attrs["llm.input_messages.0.message.role"] == "user"
68
+ assert attrs["llm.input_messages.0.message.content"] == "hello"
69
+ assert attrs["llm.token_count.prompt_details.cache_read"] == 7
70
+ assert attrs["acty.run_name"] == "telemetry-run"