aiqa-client 0.4.7__py3-none-any.whl → 0.6.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.
- aiqa/__init__.py +9 -3
- aiqa/client.py +113 -16
- aiqa/constants.py +1 -1
- aiqa/experiment_runner.py +248 -77
- aiqa/http_utils.py +85 -11
- aiqa/llm_as_judge.py +281 -0
- aiqa/span_helpers.py +511 -0
- aiqa/tracing.py +202 -566
- aiqa/tracing_llm_utils.py +20 -9
- aiqa/types.py +61 -0
- {aiqa_client-0.4.7.dist-info → aiqa_client-0.6.1.dist-info}/METADATA +1 -1
- aiqa_client-0.6.1.dist-info/RECORD +17 -0
- {aiqa_client-0.4.7.dist-info → aiqa_client-0.6.1.dist-info}/WHEEL +1 -1
- aiqa/aiqa_exporter.py +0 -772
- aiqa_client-0.4.7.dist-info/RECORD +0 -15
- {aiqa_client-0.4.7.dist-info → aiqa_client-0.6.1.dist-info}/licenses/LICENSE.txt +0 -0
- {aiqa_client-0.4.7.dist-info → aiqa_client-0.6.1.dist-info}/top_level.txt +0 -0
aiqa/experiment_runner.py
CHANGED
|
@@ -4,10 +4,52 @@ ExperimentRunner - runs experiments on datasets and scores results
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import time
|
|
7
|
+
import asyncio
|
|
7
8
|
from .constants import LOG_TAG
|
|
8
9
|
from .http_utils import build_headers, get_server_url, get_api_key, format_http_error
|
|
9
10
|
from typing import Any, Dict, List, Optional, Callable, Awaitable, Union
|
|
11
|
+
from .tracing import WithTracing
|
|
12
|
+
from .span_helpers import set_span_attribute, flush_tracing
|
|
13
|
+
from .llm_as_judge import score_llm_metric_local, get_model_from_server, call_llm_fallback
|
|
10
14
|
import requests
|
|
15
|
+
from .types import MetricResult, ScoreThisInputOutputMetricType, Example, Result, Metric, CallLLMType
|
|
16
|
+
|
|
17
|
+
# Type aliases for engine/scoring functions to improve code completion and clarity
|
|
18
|
+
from typing import TypedDict
|
|
19
|
+
|
|
20
|
+
# Function that processes input and parameters to produce an output (sync or async)
|
|
21
|
+
CallMyCodeType = Callable[[Any, Dict[str, Any]], Union[Any, Awaitable[Any]]]
|
|
22
|
+
|
|
23
|
+
# Function that scores a given output, using input, example, and parameters (usually async)
|
|
24
|
+
# Returns a dictionary with score/message/etc.
|
|
25
|
+
ScoreThisOutputType = Callable[[Any, Any, Dict[str, Any], Dict[str, Any]], Awaitable[Dict[str, Any]]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _filter_input_for_run(input_data: Any) -> Dict[str, Any]:
|
|
30
|
+
"""Tracing:Filter input - drop most, keep just ids"""
|
|
31
|
+
if not isinstance(input_data, dict):
|
|
32
|
+
return {}
|
|
33
|
+
self_obj = input_data.get("self")
|
|
34
|
+
if not self_obj:
|
|
35
|
+
return {}
|
|
36
|
+
return {
|
|
37
|
+
"dataset": getattr(self_obj, "dataset_id", None),
|
|
38
|
+
"experiment": getattr(self_obj, "experiment_id", None),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _filter_input_for_run_example(
|
|
43
|
+
self: "ExperimentRunner",
|
|
44
|
+
example: Dict[str, Any],
|
|
45
|
+
call_my_code: Any = None,
|
|
46
|
+
score_this_output: Any = None,
|
|
47
|
+
) -> Dict[str, Any]:
|
|
48
|
+
"""Filter input for run_example method to extract dataset, experiment, and example IDs."""
|
|
49
|
+
result = _filter_input_for_run({"self": self})
|
|
50
|
+
if isinstance(example, dict):
|
|
51
|
+
result["example"] = example.get("id")
|
|
52
|
+
return result
|
|
11
53
|
|
|
12
54
|
|
|
13
55
|
class ExperimentRunner:
|
|
@@ -24,6 +66,7 @@ class ExperimentRunner:
|
|
|
24
66
|
server_url: Optional[str] = None,
|
|
25
67
|
api_key: Optional[str] = None,
|
|
26
68
|
organisation_id: Optional[str] = None,
|
|
69
|
+
llm_call_fn: Optional[CallLLMType] = None,
|
|
27
70
|
):
|
|
28
71
|
"""
|
|
29
72
|
Initialize the ExperimentRunner.
|
|
@@ -33,7 +76,11 @@ class ExperimentRunner:
|
|
|
33
76
|
experiment_id: Usually unset, and a fresh experiment is created with a random ID
|
|
34
77
|
server_url: URL of the AIQA server (defaults to AIQA_SERVER_URL env var)
|
|
35
78
|
api_key: API key for authentication (defaults to AIQA_API_KEY env var)
|
|
36
|
-
organisation_id:
|
|
79
|
+
organisation_id: Optional organisation ID for the experiment. If not provided, will be
|
|
80
|
+
derived from the dataset when needed.
|
|
81
|
+
llm_call_fn: Optional async function that takes (system_prompt, user_message) and returns
|
|
82
|
+
raw content string (typically JSON). If not provided, will check for OPENAI_API_KEY
|
|
83
|
+
or ANTHROPIC_API_KEY environment variables.
|
|
37
84
|
"""
|
|
38
85
|
self.dataset_id = dataset_id
|
|
39
86
|
self.experiment_id = experiment_id
|
|
@@ -42,6 +89,8 @@ class ExperimentRunner:
|
|
|
42
89
|
self.organisation = organisation_id
|
|
43
90
|
self.experiment: Optional[Dict[str, Any]] = None
|
|
44
91
|
self.scores: List[Dict[str, Any]] = []
|
|
92
|
+
self.llm_call_fn = llm_call_fn
|
|
93
|
+
self._dataset_cache: Optional[Dict[str, Any]] = None
|
|
45
94
|
|
|
46
95
|
def _get_headers(self) -> Dict[str, str]:
|
|
47
96
|
"""Build HTTP headers for API requests."""
|
|
@@ -54,6 +103,9 @@ class ExperimentRunner:
|
|
|
54
103
|
Returns:
|
|
55
104
|
The dataset object with metrics and other information
|
|
56
105
|
"""
|
|
106
|
+
if self._dataset_cache is not None:
|
|
107
|
+
return self._dataset_cache
|
|
108
|
+
|
|
57
109
|
response = requests.get(
|
|
58
110
|
f"{self.server_url}/dataset/{self.dataset_id}",
|
|
59
111
|
headers=self._get_headers(),
|
|
@@ -62,7 +114,14 @@ class ExperimentRunner:
|
|
|
62
114
|
if not response.ok:
|
|
63
115
|
raise Exception(format_http_error(response, "fetch dataset"))
|
|
64
116
|
|
|
65
|
-
|
|
117
|
+
dataset = response.json()
|
|
118
|
+
self._dataset_cache = dataset
|
|
119
|
+
|
|
120
|
+
# If organisation_id wasn't set, derive it from the dataset
|
|
121
|
+
if not self.organisation and dataset.get("organisation"):
|
|
122
|
+
self.organisation = dataset.get("organisation")
|
|
123
|
+
|
|
124
|
+
return dataset
|
|
66
125
|
|
|
67
126
|
def get_example_inputs(self, limit: int = 10000) -> List[Dict[str, Any]]:
|
|
68
127
|
"""
|
|
@@ -108,8 +167,13 @@ class ExperimentRunner:
|
|
|
108
167
|
Returns:
|
|
109
168
|
The created experiment object
|
|
110
169
|
"""
|
|
170
|
+
# Ensure we have the organisation ID - try to get it from the dataset if not set
|
|
171
|
+
if not self.organisation:
|
|
172
|
+
dataset = self.get_dataset()
|
|
173
|
+
self.organisation = dataset.get("organisation")
|
|
174
|
+
|
|
111
175
|
if not self.organisation or not self.dataset_id:
|
|
112
|
-
raise Exception("Organisation and dataset ID are required to create an experiment")
|
|
176
|
+
raise Exception("Organisation and dataset ID are required to create an experiment. Organisation can be derived from the dataset or set via organisation_id parameter.")
|
|
113
177
|
|
|
114
178
|
if not experiment_setup:
|
|
115
179
|
experiment_setup = {}
|
|
@@ -138,19 +202,19 @@ class ExperimentRunner:
|
|
|
138
202
|
self.experiment = experiment
|
|
139
203
|
return experiment
|
|
140
204
|
|
|
141
|
-
def score_and_store(
|
|
205
|
+
async def score_and_store(
|
|
142
206
|
self,
|
|
143
|
-
example:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
) ->
|
|
207
|
+
example: Example,
|
|
208
|
+
output: Any,
|
|
209
|
+
result: Result,
|
|
210
|
+
) -> Result:
|
|
147
211
|
"""
|
|
148
212
|
Ask the server to score an example result. Stores the score for later summary calculation.
|
|
149
213
|
|
|
150
214
|
Args:
|
|
151
215
|
example: The example object
|
|
152
|
-
|
|
153
|
-
|
|
216
|
+
output: The output from running the engine on the example
|
|
217
|
+
result: The result object for locally calculated scores
|
|
154
218
|
|
|
155
219
|
Returns:
|
|
156
220
|
The score result from the server
|
|
@@ -158,22 +222,34 @@ class ExperimentRunner:
|
|
|
158
222
|
# Do we have an experiment ID? If not, we need to create the experiment first
|
|
159
223
|
if not self.experiment_id:
|
|
160
224
|
self.create_experiment()
|
|
161
|
-
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
225
|
+
example_id = example.get("id")
|
|
226
|
+
if not example_id:
|
|
227
|
+
raise ValueError("Example must have an 'id' field")
|
|
228
|
+
if result is None:
|
|
229
|
+
example_id = example.get("id")
|
|
230
|
+
if not example_id:
|
|
231
|
+
raise ValueError("Example must have an 'id' field")
|
|
232
|
+
result = Result(exampleId=example_id, scores={}, messages={}, errors={})
|
|
233
|
+
scores = result.get("scores") or {}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
print(f"Scoring and storing example: {example_id}")
|
|
166
238
|
print(f"Scores: {scores}")
|
|
167
239
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
240
|
+
# Run synchronous requests.post in a thread pool to avoid blocking
|
|
241
|
+
def _do_request():
|
|
242
|
+
return requests.post(
|
|
243
|
+
f"{self.server_url}/experiment/{self.experiment_id}/example/{example_id}/scoreAndStore",
|
|
244
|
+
json={
|
|
245
|
+
"output": result,
|
|
246
|
+
"traceId": example.get("traceId"),
|
|
247
|
+
"scores": scores,
|
|
248
|
+
},
|
|
249
|
+
headers=self._get_headers(),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
response = await asyncio.to_thread(_do_request)
|
|
177
253
|
|
|
178
254
|
if not response.ok:
|
|
179
255
|
raise Exception(format_http_error(response, "score and store"))
|
|
@@ -182,12 +258,11 @@ class ExperimentRunner:
|
|
|
182
258
|
print(f"scoreAndStore response: {json_result}")
|
|
183
259
|
return json_result
|
|
184
260
|
|
|
261
|
+
@WithTracing(filter_input=_filter_input_for_run)
|
|
185
262
|
async def run(
|
|
186
263
|
self,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
Callable[[Any, Dict[str, Any]], Awaitable[Dict[str, Any]]]
|
|
190
|
-
] = None,
|
|
264
|
+
call_my_code: CallMyCodeType,
|
|
265
|
+
scorer_for_metric_id: Optional[Dict[str, ScoreThisInputOutputMetricType]] = None,
|
|
191
266
|
) -> None:
|
|
192
267
|
"""
|
|
193
268
|
Run an engine function on all examples and score the results.
|
|
@@ -199,42 +274,43 @@ class ExperimentRunner:
|
|
|
199
274
|
examples = self.get_example_inputs()
|
|
200
275
|
|
|
201
276
|
# Wrap engine to match run_example signature (input, parameters)
|
|
202
|
-
def wrapped_engine(input_data, parameters):
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return await scorer(output, example)
|
|
209
|
-
return {}
|
|
277
|
+
async def wrapped_engine(input_data, parameters):
|
|
278
|
+
result = call_my_code(input_data, parameters)
|
|
279
|
+
# Handle async functions
|
|
280
|
+
if hasattr(result, "__await__"):
|
|
281
|
+
result = await result
|
|
282
|
+
return result
|
|
210
283
|
|
|
211
284
|
for example in examples:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
285
|
+
try:
|
|
286
|
+
scores = await self.run_example(example, wrapped_engine, scorer_for_metric_id)
|
|
287
|
+
if scores:
|
|
288
|
+
self.scores.append(
|
|
289
|
+
{
|
|
290
|
+
"example": example,
|
|
291
|
+
"result": scores,
|
|
292
|
+
"scores": scores,
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print(f"Error processing example {example.get('id', 'unknown')}: {e}")
|
|
297
|
+
# Continue with next example instead of failing entire run
|
|
298
|
+
|
|
299
|
+
@WithTracing(filter_input=_filter_input_for_run_example)
|
|
222
300
|
async def run_example(
|
|
223
301
|
self,
|
|
224
|
-
example:
|
|
225
|
-
call_my_code:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
] = None,
|
|
229
|
-
) -> List[Dict[str, Any]]:
|
|
302
|
+
example: Example,
|
|
303
|
+
call_my_code: CallMyCodeType,
|
|
304
|
+
scorer_for_metric_id: Optional[Dict[str, ScoreThisInputOutputMetricType]] = None,
|
|
305
|
+
) -> List[Result]:
|
|
230
306
|
"""
|
|
231
307
|
Run the engine on an example with the given parameters (looping over comparison parameters),
|
|
232
308
|
and score the result. Also calls scoreAndStore to store the result in the server.
|
|
233
309
|
|
|
234
310
|
Args:
|
|
235
|
-
example: The example to run
|
|
311
|
+
example: The example to run. See Example.ts type
|
|
236
312
|
call_my_code: Function that takes input and parameters, returns output (can be async)
|
|
237
|
-
|
|
313
|
+
scorer_for_metric_id: Optional dictionary of metric IDs to functions that score the output given the example and parameters
|
|
238
314
|
|
|
239
315
|
Returns:
|
|
240
316
|
One set of scores for each comparison parameter set. If no comparison parameters,
|
|
@@ -261,41 +337,87 @@ class ExperimentRunner:
|
|
|
261
337
|
)
|
|
262
338
|
# Run engine anyway -- this could make sense if it's all about the parameters
|
|
263
339
|
|
|
340
|
+
# Set example.id on the root span (created by @WithTracing decorator)
|
|
341
|
+
# This ensures the root span from the trace has example=Example.id set
|
|
342
|
+
example_id = example.get("id")
|
|
343
|
+
if not example_id:
|
|
344
|
+
raise ValueError("Example must have an 'id' field")
|
|
345
|
+
set_span_attribute("example", example_id)
|
|
346
|
+
|
|
264
347
|
all_scores: List[Dict[str, Any]] = []
|
|
348
|
+
dataset_metrics = self.get_dataset().get("metrics", [])
|
|
349
|
+
specific_metrics = example.get("metrics", [])
|
|
350
|
+
metrics = [*dataset_metrics, *specific_metrics]
|
|
265
351
|
# This loop should not be parallelized - it should run sequentially, one after the other
|
|
266
352
|
# to avoid creating interference between the runs.
|
|
267
353
|
for parameters in parameters_loop:
|
|
268
354
|
parameters_here = {**parameters_fixed, **parameters}
|
|
269
355
|
print(f"Running with parameters: {parameters_here}")
|
|
270
356
|
|
|
357
|
+
# Save original env var values for cleanup
|
|
358
|
+
original_env_vars: Dict[str, Optional[str]] = {}
|
|
271
359
|
# Set env vars from parameters_here
|
|
272
360
|
for key, value in parameters_here.items():
|
|
273
361
|
if value:
|
|
362
|
+
original_env_vars[key] = os.environ.get(key)
|
|
274
363
|
os.environ[key] = str(value)
|
|
275
364
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
365
|
+
try:
|
|
366
|
+
start = time.time() * 1000 # milliseconds
|
|
367
|
+
output = call_my_code(input_data, parameters_here)
|
|
368
|
+
# Handle async functions
|
|
369
|
+
if hasattr(output, "__await__"):
|
|
370
|
+
output = await output
|
|
371
|
+
end = time.time() * 1000 # milliseconds
|
|
372
|
+
duration = int(end - start)
|
|
373
|
+
|
|
374
|
+
print(f"Output: {output}")
|
|
375
|
+
# Score it
|
|
376
|
+
result = Result(exampleId=example_id, scores={}, messages={}, errors={})
|
|
377
|
+
for metric in metrics:
|
|
378
|
+
metric_id = metric.get("id")
|
|
379
|
+
if not metric_id:
|
|
380
|
+
print(f"Warning: Metric missing 'id' field, skipping: {metric}")
|
|
381
|
+
continue
|
|
382
|
+
scorer = scorer_for_metric_id.get(metric_id) if scorer_for_metric_id else None
|
|
383
|
+
if scorer:
|
|
384
|
+
metric_result = await scorer(input_data, output, metric)
|
|
385
|
+
elif metric.get("type") == "llm":
|
|
386
|
+
metric_result = await self._score_llm_metric(input_data, output, example, metric)
|
|
387
|
+
else:
|
|
388
|
+
metric_type = metric.get("type", "unknown")
|
|
389
|
+
print(f"Skipping metric: {metric_id} {metric_type} - no scorer")
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
# Handle None metric_result (e.g., if scoring failed)
|
|
393
|
+
if not metric_result:
|
|
394
|
+
print(f"Warning: Metric {metric_id} returned None result, skipping")
|
|
395
|
+
result["errors"][metric_id] = "Scoring function returned None"
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
result["scores"][metric_id] = metric_result.get("score")
|
|
399
|
+
result["messages"][metric_id] = metric_result.get("message")
|
|
400
|
+
result["errors"][metric_id] = metric_result.get("error")
|
|
401
|
+
# Always add duration to scores as a system metric
|
|
402
|
+
result["scores"]["duration"] = duration
|
|
403
|
+
|
|
404
|
+
# Flush spans before scoreAndStore to ensure they're indexed in ES
|
|
405
|
+
# This prevents race condition where scoreAndStore looks up spans before they're indexed
|
|
406
|
+
await flush_tracing()
|
|
407
|
+
|
|
408
|
+
print(f"Call scoreAndStore ... for example: {example_id} with scores: {result['scores']}")
|
|
409
|
+
result = await self.score_and_store(example, output, result)
|
|
410
|
+
print(f"scoreAndStore returned: {result}")
|
|
411
|
+
all_scores.append(result)
|
|
412
|
+
finally:
|
|
413
|
+
# Restore original env var values
|
|
414
|
+
for key, original_value in original_env_vars.items():
|
|
415
|
+
if original_value is None:
|
|
416
|
+
# Variable didn't exist before, remove it
|
|
417
|
+
os.environ.pop(key, None)
|
|
418
|
+
else:
|
|
419
|
+
# Restore original value
|
|
420
|
+
os.environ[key] = original_value
|
|
299
421
|
|
|
300
422
|
return all_scores
|
|
301
423
|
|
|
@@ -306,6 +428,9 @@ class ExperimentRunner:
|
|
|
306
428
|
Returns:
|
|
307
429
|
Dictionary of metric names to summary statistics
|
|
308
430
|
"""
|
|
431
|
+
if not self.experiment_id:
|
|
432
|
+
raise ValueError("No experiment ID available. Create an experiment first.")
|
|
433
|
+
|
|
309
434
|
response = requests.get(
|
|
310
435
|
f"{self.server_url}/experiment/{self.experiment_id}",
|
|
311
436
|
headers=self._get_headers(),
|
|
@@ -317,3 +442,49 @@ class ExperimentRunner:
|
|
|
317
442
|
experiment2 = response.json()
|
|
318
443
|
return experiment2.get("summary_results", {})
|
|
319
444
|
|
|
445
|
+
async def _score_llm_metric(
|
|
446
|
+
self,
|
|
447
|
+
input_data: Any,
|
|
448
|
+
output: Any,
|
|
449
|
+
example: Example,
|
|
450
|
+
metric: Metric,
|
|
451
|
+
) -> MetricResult:
|
|
452
|
+
"""
|
|
453
|
+
Score an LLM metric by fetching model API key from server if needed.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
input_data: The input data to score
|
|
457
|
+
output: The output to score
|
|
458
|
+
example: The example object
|
|
459
|
+
metric: The metric definition
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
MetricResult object with score:[0,1], message (optional), and error (optional)
|
|
463
|
+
"""
|
|
464
|
+
# If model is specified, try to fetch API key from server
|
|
465
|
+
model_id = metric.get("model")
|
|
466
|
+
api_key = None
|
|
467
|
+
provider = metric.get("provider")
|
|
468
|
+
|
|
469
|
+
if model_id:
|
|
470
|
+
model_data = await get_model_from_server(
|
|
471
|
+
model_id, self.server_url, self._get_headers()
|
|
472
|
+
)
|
|
473
|
+
if model_data:
|
|
474
|
+
api_key = model_data.get("api_key")
|
|
475
|
+
# If provider not set in metric, try to get it from model
|
|
476
|
+
if not provider and model_data.get("provider"):
|
|
477
|
+
provider = model_data.get("provider")
|
|
478
|
+
|
|
479
|
+
# Create a custom llm_call_fn if we have an API key from the model
|
|
480
|
+
llm_call_fn = self.llm_call_fn
|
|
481
|
+
if api_key and not llm_call_fn:
|
|
482
|
+
async def _model_llm_call(system_prompt: str, user_message: str) -> str:
|
|
483
|
+
return await call_llm_fallback(system_prompt, user_message, api_key, provider)
|
|
484
|
+
llm_call_fn = _model_llm_call
|
|
485
|
+
|
|
486
|
+
return await score_llm_metric_local(
|
|
487
|
+
input_data, output, example, metric, llm_call_fn
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
aiqa/http_utils.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Shared HTTP utilities for AIQA client.
|
|
3
3
|
Provides common functions for building headers, handling errors, and accessing environment variables.
|
|
4
|
+
Supports AIQA-specific env vars (AIQA_SERVER_URL, AIQA_API_KEY) with fallback to OTLP standard vars.
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
import os
|
|
@@ -11,17 +12,46 @@ def build_headers(api_key: Optional[str] = None) -> Dict[str, str]:
|
|
|
11
12
|
"""
|
|
12
13
|
Build HTTP headers for AIQA API requests.
|
|
13
14
|
|
|
15
|
+
Checks AIQA_API_KEY first, then falls back to OTEL_EXPORTER_OTLP_HEADERS if not set.
|
|
16
|
+
|
|
14
17
|
Args:
|
|
15
|
-
api_key: Optional API key. If not provided, will try to get from AIQA_API_KEY env var
|
|
18
|
+
api_key: Optional API key. If not provided, will try to get from AIQA_API_KEY env var,
|
|
19
|
+
then from OTEL_EXPORTER_OTLP_HEADERS.
|
|
16
20
|
|
|
17
21
|
Returns:
|
|
18
|
-
Dictionary with Content-Type and optionally Authorization header.
|
|
22
|
+
Dictionary with Content-Type, Accept-Encoding, and optionally Authorization header.
|
|
19
23
|
"""
|
|
20
|
-
headers = {
|
|
24
|
+
headers = {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"Accept-Encoding": "gzip, deflate, br", # Request compression (aiohttp handles decompression automatically)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Check parameter first
|
|
21
30
|
if api_key:
|
|
22
31
|
headers["Authorization"] = f"ApiKey {api_key}"
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
return headers
|
|
33
|
+
|
|
34
|
+
# Check AIQA_API_KEY env var
|
|
35
|
+
aiqa_api_key = os.getenv("AIQA_API_KEY")
|
|
36
|
+
if aiqa_api_key:
|
|
37
|
+
headers["Authorization"] = f"ApiKey {aiqa_api_key}"
|
|
38
|
+
return headers
|
|
39
|
+
|
|
40
|
+
# Fallback to OTLP headers (format: "key1=value1,key2=value2")
|
|
41
|
+
otlp_headers = os.getenv("OTEL_EXPORTER_OTLP_HEADERS")
|
|
42
|
+
if otlp_headers:
|
|
43
|
+
# Parse comma-separated key=value pairs
|
|
44
|
+
for header_pair in otlp_headers.split(","):
|
|
45
|
+
header_pair = header_pair.strip()
|
|
46
|
+
if "=" in header_pair:
|
|
47
|
+
key, value = header_pair.split("=", 1)
|
|
48
|
+
key = key.strip()
|
|
49
|
+
value = value.strip()
|
|
50
|
+
if key.lower() == "authorization":
|
|
51
|
+
headers["Authorization"] = value
|
|
52
|
+
else:
|
|
53
|
+
headers[key] = value
|
|
54
|
+
|
|
25
55
|
return headers
|
|
26
56
|
|
|
27
57
|
|
|
@@ -29,27 +59,71 @@ def get_server_url(server_url: Optional[str] = None) -> str:
|
|
|
29
59
|
"""
|
|
30
60
|
Get server URL from parameter or environment variable, with trailing slash removed.
|
|
31
61
|
|
|
62
|
+
Checks AIQA_SERVER_URL first, then falls back to OTEL_EXPORTER_OTLP_ENDPOINT if not set.
|
|
63
|
+
|
|
32
64
|
Args:
|
|
33
|
-
server_url: Optional server URL. If not provided, will get from AIQA_SERVER_URL env var
|
|
65
|
+
server_url: Optional server URL. If not provided, will get from AIQA_SERVER_URL env var,
|
|
66
|
+
then from OTEL_EXPORTER_OTLP_ENDPOINT.
|
|
34
67
|
|
|
35
68
|
Returns:
|
|
36
|
-
Server URL with trailing slash removed
|
|
69
|
+
Server URL with trailing slash removed. Defaults to https://server-aiqa.winterwell.com if not set.
|
|
37
70
|
"""
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
# Check parameter first
|
|
72
|
+
if server_url:
|
|
73
|
+
return server_url.rstrip("/")
|
|
74
|
+
|
|
75
|
+
# Check AIQA_SERVER_URL env var
|
|
76
|
+
url = os.getenv("AIQA_SERVER_URL")
|
|
77
|
+
if url:
|
|
78
|
+
return url.rstrip("/")
|
|
79
|
+
|
|
80
|
+
# Fallback to OTLP endpoint
|
|
81
|
+
url = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
82
|
+
if url:
|
|
83
|
+
return url.rstrip("/")
|
|
84
|
+
|
|
85
|
+
# Default fallback
|
|
86
|
+
return "https://server-aiqa.winterwell.com"
|
|
40
87
|
|
|
41
88
|
|
|
42
89
|
def get_api_key(api_key: Optional[str] = None) -> str:
|
|
43
90
|
"""
|
|
44
91
|
Get API key from parameter or environment variable.
|
|
45
92
|
|
|
93
|
+
Checks AIQA_API_KEY first, then falls back to OTEL_EXPORTER_OTLP_HEADERS if not set.
|
|
94
|
+
|
|
46
95
|
Args:
|
|
47
|
-
api_key: Optional API key. If not provided, will get from AIQA_API_KEY env var
|
|
96
|
+
api_key: Optional API key. If not provided, will get from AIQA_API_KEY env var,
|
|
97
|
+
then from OTEL_EXPORTER_OTLP_HEADERS (looking for Authorization header).
|
|
48
98
|
|
|
49
99
|
Returns:
|
|
50
100
|
API key or empty string if not set.
|
|
51
101
|
"""
|
|
52
|
-
|
|
102
|
+
# Check parameter first
|
|
103
|
+
if api_key:
|
|
104
|
+
return api_key
|
|
105
|
+
|
|
106
|
+
# Check AIQA_API_KEY env var
|
|
107
|
+
aiqa_api_key = os.getenv("AIQA_API_KEY")
|
|
108
|
+
if aiqa_api_key:
|
|
109
|
+
return aiqa_api_key
|
|
110
|
+
|
|
111
|
+
# Fallback to OTLP headers (look for Authorization header)
|
|
112
|
+
otlp_headers = os.getenv("OTEL_EXPORTER_OTLP_HEADERS")
|
|
113
|
+
if otlp_headers:
|
|
114
|
+
for header_pair in otlp_headers.split(","):
|
|
115
|
+
header_pair = header_pair.strip()
|
|
116
|
+
if "=" in header_pair:
|
|
117
|
+
key, value = header_pair.split("=", 1)
|
|
118
|
+
key = key.strip()
|
|
119
|
+
value = value.strip()
|
|
120
|
+
if key.lower() == "authorization":
|
|
121
|
+
# Extract API key from "ApiKey <key>" or just return the value
|
|
122
|
+
if value.startswith("ApiKey "):
|
|
123
|
+
return value[7:]
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
return ""
|
|
53
127
|
|
|
54
128
|
|
|
55
129
|
def format_http_error(response, operation: str) -> str:
|