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.
- acty_openai-0.1.0/.gitignore +8 -0
- acty_openai-0.1.0/LICENSE +21 -0
- acty_openai-0.1.0/PKG-INFO +89 -0
- acty_openai-0.1.0/README.md +59 -0
- acty_openai-0.1.0/pyproject.toml +45 -0
- acty_openai-0.1.0/pytest.ini +4 -0
- acty_openai-0.1.0/src/acty_openai/__init__.py +15 -0
- acty_openai-0.1.0/src/acty_openai/executor.py +506 -0
- acty_openai-0.1.0/tests/test_error_classification.py +19 -0
- acty_openai-0.1.0/tests/test_executor.py +96 -0
- acty_openai-0.1.0/tests/test_openai_engine_integration.py +70 -0
|
@@ -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,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"
|