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/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: Organisation ID for the experiment
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
- return response.json()
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: Dict[str, Any],
144
- result: Any,
145
- scores: Optional[Dict[str, Any]] = None,
146
- ) -> Dict[str, Any]:
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
- result: The output from running the engine on the example
153
- scores: Optional pre-computed scores
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 scores is None:
163
- scores = {}
164
-
165
- print(f"Scoring and storing example: {example['id']}")
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
- response = requests.post(
169
- f"{self.server_url}/experiment/{self.experiment_id}/example/{example['id']}/scoreAndStore",
170
- json={
171
- "output": result,
172
- "traceId": example.get("traceId"),
173
- "scores": scores,
174
- },
175
- headers=self._get_headers(),
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
- engine: Callable[[Any], Union[Any, Awaitable[Any]]],
188
- scorer: Optional[
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
- return engine(input_data)
204
-
205
- # Wrap scorer to match run_example signature (output, example, parameters)
206
- async def wrapped_scorer(output, example, parameters):
207
- if scorer:
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
- scores = await self.run_example(example, wrapped_engine, wrapped_scorer)
213
- if scores:
214
- self.scores.append(
215
- {
216
- "example": example,
217
- "result": scores,
218
- "scores": scores,
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: Dict[str, Any],
225
- call_my_code: Callable[[Any, Dict[str, Any]], Union[Any, Awaitable[Any]]],
226
- score_this_output: Optional[
227
- Callable[[Any, Dict[str, Any], Dict[str, Any]], Awaitable[Dict[str, Any]]]
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
- score_this_output: Optional function that scores the output given the example and parameters
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
- start = time.time() * 1000 # milliseconds
277
- output = call_my_code(input_data, parameters_here)
278
- # Handle async functions
279
- if hasattr(output, "__await__"):
280
- import asyncio
281
-
282
- output = await output
283
- end = time.time() * 1000 # milliseconds
284
- duration = int(end - start)
285
-
286
- print(f"Output: {output}")
287
-
288
- scores: Dict[str, Any] = {}
289
- if score_this_output:
290
- scores = await score_this_output(output, example, parameters_here)
291
-
292
- scores["duration"] = duration
293
-
294
- # TODO: this call as async and wait for all to complete before returning
295
- print(f"Call scoreAndStore ... for example: {example['id']} with scores: {scores}")
296
- result = self.score_and_store(example, output, scores)
297
- print(f"scoreAndStore returned: {result}")
298
- all_scores.append(result)
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 = {"Content-Type": "application/json"}
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
- elif os.getenv("AIQA_API_KEY"):
24
- headers["Authorization"] = f"ApiKey {os.getenv('AIQA_API_KEY')}"
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, or empty string if not set.
69
+ Server URL with trailing slash removed. Defaults to https://server-aiqa.winterwell.com if not set.
37
70
  """
38
- url = server_url or os.getenv("AIQA_SERVER_URL", "")
39
- return url.rstrip("/")
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
- return api_key or os.getenv("AIQA_API_KEY", "")
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: