prompture 0.0.38.dev2__py3-none-any.whl → 0.0.42__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.
- prompture/__init__.py +12 -1
- prompture/_version.py +2 -2
- prompture/agent.py +11 -11
- prompture/async_agent.py +11 -11
- prompture/async_conversation.py +9 -0
- prompture/async_core.py +16 -0
- prompture/async_driver.py +39 -0
- prompture/async_groups.py +63 -0
- prompture/conversation.py +9 -0
- prompture/core.py +16 -0
- prompture/cost_mixin.py +62 -0
- prompture/discovery.py +108 -43
- prompture/driver.py +39 -0
- prompture/drivers/__init__.py +39 -0
- prompture/drivers/async_azure_driver.py +7 -6
- prompture/drivers/async_claude_driver.py +177 -8
- prompture/drivers/async_google_driver.py +10 -0
- prompture/drivers/async_grok_driver.py +4 -4
- prompture/drivers/async_groq_driver.py +4 -4
- prompture/drivers/async_modelscope_driver.py +286 -0
- prompture/drivers/async_moonshot_driver.py +312 -0
- prompture/drivers/async_openai_driver.py +158 -6
- prompture/drivers/async_openrouter_driver.py +196 -7
- prompture/drivers/async_registry.py +30 -0
- prompture/drivers/async_zai_driver.py +303 -0
- prompture/drivers/azure_driver.py +6 -5
- prompture/drivers/claude_driver.py +10 -0
- prompture/drivers/google_driver.py +10 -0
- prompture/drivers/grok_driver.py +4 -4
- prompture/drivers/groq_driver.py +4 -4
- prompture/drivers/modelscope_driver.py +303 -0
- prompture/drivers/moonshot_driver.py +342 -0
- prompture/drivers/openai_driver.py +22 -12
- prompture/drivers/openrouter_driver.py +248 -44
- prompture/drivers/zai_driver.py +318 -0
- prompture/groups.py +42 -0
- prompture/ledger.py +252 -0
- prompture/model_rates.py +114 -2
- prompture/settings.py +16 -1
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/METADATA +1 -1
- prompture-0.0.42.dist-info/RECORD +84 -0
- prompture-0.0.38.dev2.dist-info/RECORD +0 -77
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/WHEEL +0 -0
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/top_level.txt +0 -0
|
@@ -12,7 +12,7 @@ try:
|
|
|
12
12
|
except Exception:
|
|
13
13
|
OpenAI = None
|
|
14
14
|
|
|
15
|
-
from ..cost_mixin import CostMixin
|
|
15
|
+
from ..cost_mixin import CostMixin, prepare_strict_schema
|
|
16
16
|
from ..driver import Driver
|
|
17
17
|
|
|
18
18
|
|
|
@@ -93,10 +93,17 @@ class OpenAIDriver(CostMixin, Driver):
|
|
|
93
93
|
|
|
94
94
|
model = options.get("model", self.model)
|
|
95
95
|
|
|
96
|
-
# Lookup model-specific config
|
|
97
|
-
|
|
98
|
-
tokens_param =
|
|
99
|
-
supports_temperature =
|
|
96
|
+
# Lookup model-specific config (live models.dev data + hardcoded fallback)
|
|
97
|
+
model_config = self._get_model_config("openai", model)
|
|
98
|
+
tokens_param = model_config["tokens_param"]
|
|
99
|
+
supports_temperature = model_config["supports_temperature"]
|
|
100
|
+
|
|
101
|
+
# Validate capabilities against models.dev metadata
|
|
102
|
+
self._validate_model_capabilities(
|
|
103
|
+
"openai",
|
|
104
|
+
model,
|
|
105
|
+
using_json_schema=bool(options.get("json_schema")),
|
|
106
|
+
)
|
|
100
107
|
|
|
101
108
|
# Defaults
|
|
102
109
|
opts = {"temperature": 1.0, "max_tokens": 512, **options}
|
|
@@ -118,12 +125,13 @@ class OpenAIDriver(CostMixin, Driver):
|
|
|
118
125
|
if options.get("json_mode"):
|
|
119
126
|
json_schema = options.get("json_schema")
|
|
120
127
|
if json_schema:
|
|
128
|
+
schema_copy = prepare_strict_schema(json_schema)
|
|
121
129
|
kwargs["response_format"] = {
|
|
122
130
|
"type": "json_schema",
|
|
123
131
|
"json_schema": {
|
|
124
132
|
"name": "extraction",
|
|
125
133
|
"strict": True,
|
|
126
|
-
"schema":
|
|
134
|
+
"schema": schema_copy,
|
|
127
135
|
},
|
|
128
136
|
}
|
|
129
137
|
else:
|
|
@@ -168,9 +176,11 @@ class OpenAIDriver(CostMixin, Driver):
|
|
|
168
176
|
raise RuntimeError("openai package (>=1.0.0) is not installed")
|
|
169
177
|
|
|
170
178
|
model = options.get("model", self.model)
|
|
171
|
-
|
|
172
|
-
tokens_param =
|
|
173
|
-
supports_temperature =
|
|
179
|
+
model_config = self._get_model_config("openai", model)
|
|
180
|
+
tokens_param = model_config["tokens_param"]
|
|
181
|
+
supports_temperature = model_config["supports_temperature"]
|
|
182
|
+
|
|
183
|
+
self._validate_model_capabilities("openai", model, using_tool_use=True)
|
|
174
184
|
|
|
175
185
|
opts = {"temperature": 1.0, "max_tokens": 512, **options}
|
|
176
186
|
|
|
@@ -239,9 +249,9 @@ class OpenAIDriver(CostMixin, Driver):
|
|
|
239
249
|
raise RuntimeError("openai package (>=1.0.0) is not installed")
|
|
240
250
|
|
|
241
251
|
model = options.get("model", self.model)
|
|
242
|
-
|
|
243
|
-
tokens_param =
|
|
244
|
-
supports_temperature =
|
|
252
|
+
model_config = self._get_model_config("openai", model)
|
|
253
|
+
tokens_param = model_config["tokens_param"]
|
|
254
|
+
supports_temperature = model_config["supports_temperature"]
|
|
245
255
|
|
|
246
256
|
opts = {"temperature": 1.0, "max_tokens": 512, **options}
|
|
247
257
|
|
|
@@ -2,54 +2,66 @@
|
|
|
2
2
|
Requires the `requests` package. Uses OPENROUTER_API_KEY env var.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
5
7
|
import os
|
|
8
|
+
from collections.abc import Iterator
|
|
6
9
|
from typing import Any
|
|
7
10
|
|
|
8
11
|
import requests
|
|
9
12
|
|
|
10
|
-
from ..cost_mixin import CostMixin
|
|
13
|
+
from ..cost_mixin import CostMixin, prepare_strict_schema
|
|
11
14
|
from ..driver import Driver
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class OpenRouterDriver(CostMixin, Driver):
|
|
15
18
|
supports_json_mode = True
|
|
19
|
+
supports_json_schema = True
|
|
20
|
+
supports_tool_use = True
|
|
21
|
+
supports_streaming = True
|
|
16
22
|
supports_vision = True
|
|
17
23
|
|
|
18
24
|
# Approximate pricing per 1K tokens based on OpenRouter's pricing
|
|
19
25
|
# https://openrouter.ai/docs#pricing
|
|
20
26
|
MODEL_PRICING = {
|
|
21
|
-
"openai/gpt-
|
|
22
|
-
"prompt": 0.
|
|
23
|
-
"completion": 0.
|
|
27
|
+
"openai/gpt-4o": {
|
|
28
|
+
"prompt": 0.005,
|
|
29
|
+
"completion": 0.015,
|
|
24
30
|
"tokens_param": "max_tokens",
|
|
25
31
|
"supports_temperature": True,
|
|
26
32
|
},
|
|
27
|
-
"
|
|
28
|
-
"prompt": 0.
|
|
29
|
-
"completion": 0.
|
|
33
|
+
"openai/gpt-4o-mini": {
|
|
34
|
+
"prompt": 0.00015,
|
|
35
|
+
"completion": 0.0006,
|
|
30
36
|
"tokens_param": "max_tokens",
|
|
31
37
|
"supports_temperature": True,
|
|
32
38
|
},
|
|
33
|
-
"
|
|
34
|
-
"prompt": 0.
|
|
35
|
-
"completion": 0.
|
|
39
|
+
"anthropic/claude-sonnet-4-20250514": {
|
|
40
|
+
"prompt": 0.003,
|
|
41
|
+
"completion": 0.015,
|
|
36
42
|
"tokens_param": "max_tokens",
|
|
37
43
|
"supports_temperature": True,
|
|
38
44
|
},
|
|
39
|
-
"
|
|
40
|
-
"prompt": 0.
|
|
41
|
-
"completion": 0.
|
|
45
|
+
"google/gemini-2.0-flash-001": {
|
|
46
|
+
"prompt": 0.0001,
|
|
47
|
+
"completion": 0.0004,
|
|
48
|
+
"tokens_param": "max_tokens",
|
|
49
|
+
"supports_temperature": True,
|
|
50
|
+
},
|
|
51
|
+
"meta-llama/llama-3.1-70b-instruct": {
|
|
52
|
+
"prompt": 0.0004,
|
|
53
|
+
"completion": 0.0004,
|
|
42
54
|
"tokens_param": "max_tokens",
|
|
43
55
|
"supports_temperature": True,
|
|
44
56
|
},
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
def __init__(self, api_key: str | None = None, model: str = "openai/gpt-
|
|
59
|
+
def __init__(self, api_key: str | None = None, model: str = "openai/gpt-4o-mini"):
|
|
48
60
|
"""Initialize OpenRouter driver.
|
|
49
61
|
|
|
50
62
|
Args:
|
|
51
63
|
api_key: OpenRouter API key. If not provided, will look for OPENROUTER_API_KEY env var
|
|
52
|
-
model: Model to use. Defaults to openai/gpt-
|
|
64
|
+
model: Model to use. Defaults to openai/gpt-4o-mini
|
|
53
65
|
"""
|
|
54
66
|
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
|
|
55
67
|
if not self.api_key:
|
|
@@ -85,10 +97,17 @@ class OpenRouterDriver(CostMixin, Driver):
|
|
|
85
97
|
|
|
86
98
|
model = options.get("model", self.model)
|
|
87
99
|
|
|
88
|
-
# Lookup model-specific config
|
|
89
|
-
|
|
90
|
-
tokens_param =
|
|
91
|
-
supports_temperature =
|
|
100
|
+
# Lookup model-specific config (live models.dev data + hardcoded fallback)
|
|
101
|
+
model_config = self._get_model_config("openrouter", model)
|
|
102
|
+
tokens_param = model_config["tokens_param"]
|
|
103
|
+
supports_temperature = model_config["supports_temperature"]
|
|
104
|
+
|
|
105
|
+
# Validate capabilities against models.dev metadata
|
|
106
|
+
self._validate_model_capabilities(
|
|
107
|
+
"openrouter",
|
|
108
|
+
model,
|
|
109
|
+
using_json_schema=bool(options.get("json_schema")),
|
|
110
|
+
)
|
|
92
111
|
|
|
93
112
|
# Defaults
|
|
94
113
|
opts = {"temperature": 1.0, "max_tokens": 512, **options}
|
|
@@ -108,45 +127,230 @@ class OpenRouterDriver(CostMixin, Driver):
|
|
|
108
127
|
|
|
109
128
|
# Native JSON mode support
|
|
110
129
|
if options.get("json_mode"):
|
|
111
|
-
|
|
130
|
+
json_schema = options.get("json_schema")
|
|
131
|
+
if json_schema:
|
|
132
|
+
schema_copy = prepare_strict_schema(json_schema)
|
|
133
|
+
data["response_format"] = {
|
|
134
|
+
"type": "json_schema",
|
|
135
|
+
"json_schema": {
|
|
136
|
+
"name": "extraction",
|
|
137
|
+
"strict": True,
|
|
138
|
+
"schema": schema_copy,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
else:
|
|
142
|
+
data["response_format"] = {"type": "json_object"}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response = requests.post(
|
|
146
|
+
f"{self.base_url}/chat/completions",
|
|
147
|
+
headers=self.headers,
|
|
148
|
+
json=data,
|
|
149
|
+
timeout=120,
|
|
150
|
+
)
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
resp = response.json()
|
|
153
|
+
except requests.exceptions.HTTPError as e:
|
|
154
|
+
body = ""
|
|
155
|
+
if e.response is not None:
|
|
156
|
+
with contextlib.suppress(Exception):
|
|
157
|
+
body = e.response.text
|
|
158
|
+
error_msg = f"OpenRouter API request failed: {e!s}"
|
|
159
|
+
if body:
|
|
160
|
+
error_msg += f"\nResponse: {body}"
|
|
161
|
+
raise RuntimeError(error_msg) from e
|
|
162
|
+
except requests.exceptions.RequestException as e:
|
|
163
|
+
raise RuntimeError(f"OpenRouter API request failed: {e!s}") from e
|
|
164
|
+
|
|
165
|
+
# Extract usage info
|
|
166
|
+
usage = resp.get("usage", {})
|
|
167
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
168
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
169
|
+
total_tokens = usage.get("total_tokens", 0)
|
|
170
|
+
|
|
171
|
+
# Calculate cost via shared mixin
|
|
172
|
+
total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
|
|
173
|
+
|
|
174
|
+
# Standardized meta object
|
|
175
|
+
meta = {
|
|
176
|
+
"prompt_tokens": prompt_tokens,
|
|
177
|
+
"completion_tokens": completion_tokens,
|
|
178
|
+
"total_tokens": total_tokens,
|
|
179
|
+
"cost": round(total_cost, 6),
|
|
180
|
+
"raw_response": resp,
|
|
181
|
+
"model_name": model,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
text = resp["choices"][0]["message"]["content"]
|
|
185
|
+
return {"text": text, "meta": meta}
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Tool use
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def generate_messages_with_tools(
|
|
192
|
+
self,
|
|
193
|
+
messages: list[dict[str, Any]],
|
|
194
|
+
tools: list[dict[str, Any]],
|
|
195
|
+
options: dict[str, Any],
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
"""Generate a response that may include tool calls."""
|
|
198
|
+
if not self.api_key:
|
|
199
|
+
raise RuntimeError("OpenRouter API key not found")
|
|
200
|
+
|
|
201
|
+
model = options.get("model", self.model)
|
|
202
|
+
model_config = self._get_model_config("openrouter", model)
|
|
203
|
+
tokens_param = model_config["tokens_param"]
|
|
204
|
+
supports_temperature = model_config["supports_temperature"]
|
|
205
|
+
|
|
206
|
+
self._validate_model_capabilities("openrouter", model, using_tool_use=True)
|
|
207
|
+
|
|
208
|
+
opts = {"temperature": 1.0, "max_tokens": 512, **options}
|
|
209
|
+
|
|
210
|
+
data: dict[str, Any] = {
|
|
211
|
+
"model": model,
|
|
212
|
+
"messages": messages,
|
|
213
|
+
"tools": tools,
|
|
214
|
+
}
|
|
215
|
+
data[tokens_param] = opts.get("max_tokens", 512)
|
|
216
|
+
|
|
217
|
+
if supports_temperature and "temperature" in opts:
|
|
218
|
+
data["temperature"] = opts["temperature"]
|
|
112
219
|
|
|
113
220
|
try:
|
|
114
221
|
response = requests.post(
|
|
115
222
|
f"{self.base_url}/chat/completions",
|
|
116
223
|
headers=self.headers,
|
|
117
224
|
json=data,
|
|
225
|
+
timeout=120,
|
|
118
226
|
)
|
|
119
227
|
response.raise_for_status()
|
|
120
228
|
resp = response.json()
|
|
229
|
+
except requests.exceptions.HTTPError as e:
|
|
230
|
+
error_msg = f"OpenRouter API request failed: {e!s}"
|
|
231
|
+
raise RuntimeError(error_msg) from e
|
|
232
|
+
except requests.exceptions.RequestException as e:
|
|
233
|
+
raise RuntimeError(f"OpenRouter API request failed: {e!s}") from e
|
|
234
|
+
|
|
235
|
+
usage = resp.get("usage", {})
|
|
236
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
237
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
238
|
+
total_tokens = usage.get("total_tokens", 0)
|
|
239
|
+
total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
|
|
121
240
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
241
|
+
meta = {
|
|
242
|
+
"prompt_tokens": prompt_tokens,
|
|
243
|
+
"completion_tokens": completion_tokens,
|
|
244
|
+
"total_tokens": total_tokens,
|
|
245
|
+
"cost": round(total_cost, 6),
|
|
246
|
+
"raw_response": resp,
|
|
247
|
+
"model_name": model,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
choice = resp["choices"][0]
|
|
251
|
+
text = choice["message"].get("content") or ""
|
|
252
|
+
stop_reason = choice.get("finish_reason")
|
|
253
|
+
|
|
254
|
+
tool_calls_out: list[dict[str, Any]] = []
|
|
255
|
+
for tc in choice["message"].get("tool_calls", []):
|
|
256
|
+
try:
|
|
257
|
+
args = json.loads(tc["function"]["arguments"])
|
|
258
|
+
except (json.JSONDecodeError, TypeError):
|
|
259
|
+
args = {}
|
|
260
|
+
tool_calls_out.append({
|
|
261
|
+
"id": tc["id"],
|
|
262
|
+
"name": tc["function"]["name"],
|
|
263
|
+
"arguments": args,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
"text": text,
|
|
268
|
+
"meta": meta,
|
|
269
|
+
"tool_calls": tool_calls_out,
|
|
270
|
+
"stop_reason": stop_reason,
|
|
271
|
+
}
|
|
127
272
|
|
|
128
|
-
|
|
129
|
-
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
# Streaming
|
|
275
|
+
# ------------------------------------------------------------------
|
|
130
276
|
|
|
131
|
-
|
|
132
|
-
|
|
277
|
+
def generate_messages_stream(
|
|
278
|
+
self,
|
|
279
|
+
messages: list[dict[str, Any]],
|
|
280
|
+
options: dict[str, Any],
|
|
281
|
+
) -> Iterator[dict[str, Any]]:
|
|
282
|
+
"""Yield response chunks via OpenRouter streaming API."""
|
|
283
|
+
if not self.api_key:
|
|
284
|
+
raise RuntimeError("OpenRouter API key not found")
|
|
285
|
+
|
|
286
|
+
model = options.get("model", self.model)
|
|
287
|
+
model_config = self._get_model_config("openrouter", model)
|
|
288
|
+
tokens_param = model_config["tokens_param"]
|
|
289
|
+
supports_temperature = model_config["supports_temperature"]
|
|
290
|
+
|
|
291
|
+
opts = {"temperature": 1.0, "max_tokens": 512, **options}
|
|
292
|
+
|
|
293
|
+
data: dict[str, Any] = {
|
|
294
|
+
"model": model,
|
|
295
|
+
"messages": messages,
|
|
296
|
+
"stream": True,
|
|
297
|
+
"stream_options": {"include_usage": True},
|
|
298
|
+
}
|
|
299
|
+
data[tokens_param] = opts.get("max_tokens", 512)
|
|
300
|
+
|
|
301
|
+
if supports_temperature and "temperature" in opts:
|
|
302
|
+
data["temperature"] = opts["temperature"]
|
|
303
|
+
|
|
304
|
+
response = requests.post(
|
|
305
|
+
f"{self.base_url}/chat/completions",
|
|
306
|
+
headers=self.headers,
|
|
307
|
+
json=data,
|
|
308
|
+
stream=True,
|
|
309
|
+
timeout=120,
|
|
310
|
+
)
|
|
311
|
+
response.raise_for_status()
|
|
312
|
+
|
|
313
|
+
full_text = ""
|
|
314
|
+
prompt_tokens = 0
|
|
315
|
+
completion_tokens = 0
|
|
316
|
+
|
|
317
|
+
for line in response.iter_lines(decode_unicode=True):
|
|
318
|
+
if not line or not line.startswith("data: "):
|
|
319
|
+
continue
|
|
320
|
+
payload = line[len("data: "):]
|
|
321
|
+
if payload.strip() == "[DONE]":
|
|
322
|
+
break
|
|
323
|
+
try:
|
|
324
|
+
chunk = json.loads(payload)
|
|
325
|
+
except json.JSONDecodeError:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
# Usage comes in the final chunk
|
|
329
|
+
usage = chunk.get("usage")
|
|
330
|
+
if usage:
|
|
331
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
332
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
333
|
+
|
|
334
|
+
choices = chunk.get("choices", [])
|
|
335
|
+
if choices:
|
|
336
|
+
delta = choices[0].get("delta", {})
|
|
337
|
+
content = delta.get("content", "")
|
|
338
|
+
if content:
|
|
339
|
+
full_text += content
|
|
340
|
+
yield {"type": "delta", "text": content}
|
|
341
|
+
|
|
342
|
+
total_tokens = prompt_tokens + completion_tokens
|
|
343
|
+
total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
|
|
344
|
+
|
|
345
|
+
yield {
|
|
346
|
+
"type": "done",
|
|
347
|
+
"text": full_text,
|
|
348
|
+
"meta": {
|
|
133
349
|
"prompt_tokens": prompt_tokens,
|
|
134
350
|
"completion_tokens": completion_tokens,
|
|
135
351
|
"total_tokens": total_tokens,
|
|
136
352
|
"cost": round(total_cost, 6),
|
|
137
|
-
"raw_response":
|
|
353
|
+
"raw_response": {},
|
|
138
354
|
"model_name": model,
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
text = resp["choices"][0]["message"]["content"]
|
|
142
|
-
return {"text": text, "meta": meta}
|
|
143
|
-
|
|
144
|
-
except requests.exceptions.RequestException as e:
|
|
145
|
-
error_msg = f"OpenRouter API request failed: {e!s}"
|
|
146
|
-
if hasattr(e.response, "json"):
|
|
147
|
-
try:
|
|
148
|
-
error_details = e.response.json()
|
|
149
|
-
error_msg = f"{error_msg} - {error_details.get('error', {}).get('message', '')}"
|
|
150
|
-
except Exception:
|
|
151
|
-
pass
|
|
152
|
-
raise RuntimeError(error_msg) from e
|
|
355
|
+
},
|
|
356
|
+
}
|